技巧 · EXTRA
(super:) —— Verse 里最容易写错的调用
重写方法之后,怎么把父类的实现也叫回来?一个长得非常怪、但一次学会终身免疫的语法。
进入拓展 →到目前为止,你的代码都住在一台设备里。这一课把「画蓝图」的能力交给你:用 class 定义自己的类型,用原型语法造出实例,再用继承让子类站在父类肩上——其实你从第 2 课起写的每台设备,早就在用它了。
假设你的关卡里要放 20 只怪物:每只都有名字、血量,还都会「挨打掉血」。如果给每只怪写一套变量和函数,代码会瞬间爆炸。程序员的解法是:先画一张蓝图,规定「怪物长什么样、会什么技能」,然后照图纸批量生产。这张蓝图,就是 class(类)。
using { /UnrealEngine.com/Temporary/Diagnostics }
# 类:类型名用小写下划线风格(和 creative_device 一样)
monster := class:
# 常量字段:没写默认值,实例化时必须提供
Name:string
# 可变字段:用 var,带默认值 100
var Health:int = 100
# 方法:写在类体内的函数,每只怪物自带
TakeDamage(Amount:int):void =
set Health -= Amount
Print("{Name} 受到 {Amount} 点伤害")
拆开看,一个类由两种零件组成:字段(数据)和方法(行为)。字段的写法你已经很熟——和第 3 课的变量一模一样:Name:string 是常量字段,一旦实例造出来就不能改;var Health:int = 100 是可变字段,以后可以 set。唯一的新知识是:字段可以不写默认值(如 Name:string),代价是每次造实例都必须当场补上。
读一遍上面这张图纸:monster 里开了两样东西——变量(数据)和蓝图函数(行为)。变量就是你在变量面板里新建的那种:Name 是字符串,故意不给默认值,又因为没写 var(相当于不给它接 Set 节点),所以它是「造出来就锁死、不给改」的常量——每照图纸造一只怪都必须当场填名字,不填 Compile 直接报红。Health 是整数、默认 100、写了 var,意味着以后可以用 Set 节点改它。最底下 set Health -= Amount 那行,就是一个「把 Health 减掉 Amount」的 Set 节点。
方法就是「住在类里的函数」,写法和第 15 课的函数完全一致。在方法体内直接写 Health,指的是「当前这只实例」的血量;如果想显式一点,可以写 Self.Health——Self 是方法体内的保留词,永远指向「正在被调用的那只实例」。命名约定也顺便记一下:类型名用 lower_snake_case(monster),字段和方法用 PascalCase(TakeDamage),跟引擎风格保持一致。
TakeDamage 是这张蓝图自带的函数图(「我的蓝图」面板里那种 f 图标的函数)。在函数图里直接拖出 Health,拿到的永远是「当前这只实例」自己的血条——等于蓝图函数默认的 Target 就是它自己;想写明白就写 Self.Health,相当于显式接一个 Self 引用当 Target。至于类型名用小写下划线、变量和函数名用大驼峰,那只是代码里的取名规矩,蓝图面板上你怎么命名引擎都认,扫一眼有个印象即可。
蓝图画好了,怎么「照图施工」?写惯其他语言的同学会条件反射敲出 new monster(...)——打住,Verse 没有 new 关键字。它用的是原型(archetype)语法:类名后面直接跟一对花括号,把要定制的字段用 := 填进去:
蓝图画好了,怎么「照图施工」造出真正的一只怪?在 Verse 里写法很短:类名后面直接跟一对花括号 { },花括号里把你想定制的字段用 := 填进去(:= 读作「把右边设成它的初始值」)。这就像你把一个 Blueprint Class 拖进关卡、在 Details 面板里逐项填它的初始值——只不过这里改用文字写出来。
Slime := monster{Name := "史莱姆"}——像不像在填一张出厂配置单?规则只有一条,但很硬:所有没有默认值的字段,必须出现在花括号里,一个都不能少,否则编译器直接打回Compile 直接给你报红。有默认值的字段(如 Health)可以不写,取默认值;也可以写上去当场覆盖。点「运行下一步」,看两只怪物怎么从同一张蓝图里走出来:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
monster := class:
Name:string
var Health:int = 100
TakeDamage(Amount:int):void =
set Health -= Amount
Print("{Name} 受到 {Amount} 点伤害,还剩 {Health} 点生命")
arena_device := class(creative_device):
OnBegin<override>()<suspends>:void =
Slime := monster{Name := "史莱姆"}
Boss := monster{Name := "巨龙", Health := 500}
Slime.TakeDamage(30)
Boss.TakeDamage(120)
点「运行下一步」,看代码怎么一行行执行。
最后两行值得多看一眼:Slime.TakeDamage(30) 和 Boss.TakeDamage(120) 调的是同一个方法,但史莱姆掉到 70、巨龙掉到 380——每只实例的字段都是独立的一份。这就是类最核心的价值:逻辑写一次,数据各存各的。
关卡越做越大,怪物开始分种类:哥布林、巨龙、幽灵……它们都有名字和血量,但每种又有自己的特色。把公共部分抄三遍?不,把公共部分留在一张「基类」蓝图里,让子类继承它——语法是在 class 后面加一对圆括号,写上父类名:
using { /UnrealEngine.com/Temporary/Diagnostics }
# 基类:所有敌人的公共蓝图
enemy := class:
Name:string = "无名敌人"
var Health:int = 100
Describe():void =
Print("{Name} 出现了!")
# 继承:goblin 自动拿到 enemy 的全部字段和方法
goblin := class(enemy):
# 重写字段默认值,同样要 <override>
Name<override>:string = "哥布林"
# 重写方法:必须加 <override>
Describe<override>():void =
Print("{Name} 举着小木棒,嗷嗷冲了过来!")
读图翻译:上半段 enemy 是父蓝图,里面开了 Name、Health 两个变量和一个 Describe 函数图。下半段 goblin := class(enemy) 就是「基于 enemy 新建一个子蓝图(Child Blueprint Class)」——哥布林自动继承那三样,什么都不用重画。哥布林只做了两处改动:把 Name 的默认值改成「哥布林」、把 Describe 函数图换成自己的版本;这两处名字后面都挂了 <override>,就等于蓝图里那个 Override 下拉——明确告诉引擎「父类这个我知道,我要盖掉它」。
三条规则,条条会考:第一,goblin := class(enemy): 之后,哥布林什么都不用写就已经拥有 Name、Health 和 Describe——继承就是「全额到账」。第二,想改父类给的东西(不管是方法实现还是字段默认值),必须在名字后面加 <override> 明确声明「我知道父类有,我就是要改」;不加它,编译器会报「成员已在父类中定义」Compile 会报红,说这个成员父类已经有了。第三,一个类只能继承一个基类——想同时具备多种「身份」,要靠下一课的 interface。
另外两个小知识先混个脸熟:给类加 <abstract> 可以把它标记为「纯图纸」——抽象类不能实例化,只能被继承,适合当公共基类;在重写的方法里想把父类的原实现也叫回来,语法是长相清奇的 (super:),坑多到值得单开一篇,见文末拓展。
另外两个进阶点先混个脸熟:给蓝图挂 <abstract>,就是把它标记成「只能当爹、不能亲自上场」的纯图纸——不能直接拿它造实例(放不进关卡),只能被别的蓝图继承,拿来当公共父类正合适;还有,在你 Override 过的函数里想把父类原来那套也照跑一遍,蓝图里对应的是右键那个「Add Call to Parent Function」,Verse 里的写法长得很怪,叫 (super:),坑多到值得单开一篇,见文末拓展。
现在回头看你写了十几课的这行代码:my_device := class(creative_device):——原来如此!你的每一台设备,都是继承了引擎基类 creative_device 的子类;而 OnBegin<override>()<suspends>:void 里那个 <override>,正是在重写父类定义好的「游戏开始时被调用」的方法。这一课没有教你新东西,只是把你一直在用的机制拆开给你看。
趁热打铁,把下面这个子类补完整:一个空填「要继承的基类名」,一个空填「重写方法必须加的 specifier重写(Override)时必须挂的那个尖括号标记」:
using { /UnrealEngine.com/Temporary/Diagnostics }
# 基类:所有敌人的公共蓝图
enemy := class:
Name:string = "无名敌人"
Describe():void =
Print("{Name} 出现了!")
# 子类:幽灵。想继承 enemy,括号里该写谁?
ghost := class(____):
Describe<____>():void =
Print("一阵冷风吹过,幽灵飘了出来……")
学会继承的第一周,人人都想把万物塞进一棵继承树:enemy 派生 flying_enemy,再派生 flying_fire_enemy……三层之后,你会发现「会喷火的宝箱」不知道该挂在哪根树杈上。老练的写法是先问一句:这个关系到底是「是一种(is-a)」还是「有一个(has-a)」?宝箱不是一种敌人,它只是「有一条」血条——那就别继承,把血条做成独立的类,装进来当字段把血条做成一个独立的小蓝图,当成一个变量塞进来。这招叫「组合」:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
# 一种可复用的「能力」:血条
health_bar := class:
var Current:int = 100
Damage(Amount:int):void =
set Current -= Amount
# 宝箱「有一条」血条(has-a),而不是「是一种」敌人(is-a)
chest := class:
Loot:string = "金币 x 100"
HP:health_bar
loot_device := class(creative_device):
OnBegin<override>()<suspends>:void =
# 组装:实例化时把血条装进宝箱
MyChest := chest{HP := health_bar{}}
MyChest.HP.Damage(25)
Print("宝箱血量:{MyChest.HP.Current}")
读图翻译:先建一个能复用的小蓝图 health_bar,里面就一个 Current 变量(默认 100)和一个 Damage 函数图(把 Current 减掉传进来的 Amount)。宝箱 chest 不去继承敌人,而是开了一个类型为 health_bar 的变量 HP——等于在宝箱蓝图里塞了一个「血条组件」。真正造宝箱时,MyChest := chest{HP := health_bar{}} 先造一条血条、再把它装进宝箱;之后 MyChest.HP.Damage(25) 就是「拿到宝箱身上那条血条,运行它的 Damage」。城门、雕像想要血条,照样各塞一个 health_bar 变量即可,互不影响。
组合的好处立竿见影:health_bar 可以同时装进宝箱、城门、雕像,谁需要血条就发给谁,完全不用理会它们在继承树上的位置;想换一套护盾逻辑,替换字段类型即可,不会牵一发动全身。经验法则:继承留给真正的「是一种」关系(goblin 是一种 enemy、你的设备是一种 creative_device),其余场景优先组合。继承是强力但僵硬的钢架,组合是灵活的乐高——大部分时候,你需要的是乐高。
▢ 坑一:重写忘加 <override>。在子类里写了个和父类同名的方法,编译器报「成员已在父类中定义」Compile 会报红,说这个成员父类已经有了——它不知道你是想重写还是手滑重名,必须用 <override> 表态。反过来也一样:给父类里不存在的成员挂 <override>,同样报错。
▢ 坑二:原型里漏掉必填字段。monster{} 看起来很省事,但只要 Name 没有默认值,这行就过不了编译这行就 Compile 不过、直接报红——所有无默认值字段必须在花括号里赋值,一个不能少。
▢ 坑三:试图实例化抽象类。<abstract> 类是纯图纸,enemy_base{} 这种写法直接编译错误,必须实例化它的具体子类。
▢ 坑三:想直接拿抽象类造实例。挂了 <abstract> 的类是纯图纸,写 enemy_base{} 会直接 Compile 报红——抽象蓝图只能当爹,你得改用它的某个具体子类来造。
▢ 坑四:以为赋值会复制对象。类是引用类型:把实例赋给另一个名字,得到的不是复印件,而是同一只怪物的第二个门牌号——
▢ 坑四:以为把对象赋给新名字就复制了一份。蓝图对象是引用——就像蓝图里的 Object Reference 变量:把 A 这个引用赋给 B,并没有复制出第二只怪,B 只是同一只怪的第二块门牌;从 B 改血,A 那边看过去也跟着变——
using { /UnrealEngine.com/Temporary/Diagnostics }
A := monster{Name := "史莱姆"}
B := A # 没有复制!B 和 A 指向同一只怪物
B.TakeDamage(30) # 通过 B 掉的血……
Print("{A.Health}") # ……从 A 看过去也少了:打印 70
通过任何一个引用修改 var 字段,其他引用全都看得见。想要「赋值即拷贝」的值语义「一赋值就真的复制出独立一份」的行为?那是下一课 struct 的主场。
蓝图画完,盖章验收。三道题,答错零惩罚,可以一直重试。
在 Verse 里,下面哪种写法能造出一只 monster 实例?
monster 的 Name 字段没有默认值。写 M := monster{} 会发生什么?
子类想重写父类的 Describe() 方法,正确做法是?