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

class 与继承:给游戏对象画蓝图

到目前为止,你的代码都住在一台设备里。这一课把「画蓝图」的能力交给你:用 class 定义自己的类型,用原型语法造出实例,再用继承让子类站在父类肩上——其实你从第 2 课起写的每台设备,早就在用它了。

一、class:一张蓝图,无数分身

假设你的关卡里要放 20 只怪物:每只都有名字、血量,还都会「挨打掉血」。如果给每只怪写一套变量和函数,代码会瞬间爆炸。程序员的解法是:先画一张蓝图,规定「怪物长什么样、会什么技能」,然后照图纸批量生产。这张蓝图,就是 class(类)。

monster.verse
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,只有花括号照图纸批量生产实例

蓝图画好了,怎么「照图施工」?写惯其他语言的同学会条件反射敲出 new monster(...)——打住,Verse 没有 new 关键字。它用的是原型(archetype)语法:类名后面直接跟一对花括号,把要定制的字段用 := 填进去:

蓝图画好了,怎么「照图施工」造出真正的一只怪?在 Verse 里写法很短:类名后面直接跟一对花括号 { },花括号里把你想定制的字段用 := 填进去(:= 读作「把右边设成它的初始值」)。这就像你把一个 Blueprint Class 拖进关卡、在 Details 面板里逐项填它的初始值——只不过这里改用文字写出来。

Slime := monster{Name := "史莱姆"}——像不像在填一张出厂配置单?规则只有一条,但很硬:所有没有默认值的字段,必须出现在花括号里,一个都不能少,否则编译器直接打回Compile 直接给你报红。有默认值的字段(如 Health)可以不写,取默认值;也可以写上去当场覆盖。点「运行下一步」,看两只怪物怎么从同一张蓝图里走出来:

arena_device.verse
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——每只实例的字段都是独立的一份。这就是类最核心的价值:逻辑写一次,数据各存各的

三、继承与 <override>:子类站在父类肩上

关卡越做越大,怪物开始分种类:哥布林、巨龙、幽灵……它们都有名字和血量,但每种又有自己的特色。把公共部分抄三遍?不,把公共部分留在一张「基类」蓝图里,让子类继承它——语法是在 class 后面加一对圆括号,写上父类名:

goblin.verse
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 是父蓝图,里面开了 NameHealth 两个变量和一个 Describe 函数图。下半段 goblin := class(enemy) 就是「基于 enemy 新建一个子蓝图(Child Blueprint Class)」——哥布林自动继承那三样,什么都不用重画。哥布林只做了两处改动:把 Name 的默认值改成「哥布林」、把 Describe 函数图换成自己的版本;这两处名字后面都挂了 <override>,就等于蓝图里那个 Override 下拉——明确告诉引擎「父类这个我知道,我要盖掉它」。

三条规则,条条会考:第一,goblin := class(enemy): 之后,哥布林什么都不用写就已经拥有 NameHealthDescribe——继承就是「全额到账」。第二,想改父类给的东西(不管是方法实现还是字段默认值),必须在名字后面加 <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)时必须挂的那个尖括号标记」:

ghost.verse
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)」?宝箱不是一种敌人,它只是「有一条」血条——那就别继承,把血条做成独立的类,装进来当字段把血条做成一个独立的小蓝图,当成一个变量塞进来。这招叫「组合」:

loot_device.verse
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),其余场景优先组合。继承是强力但僵硬的钢架,组合是灵活的乐高——大部分时候,你需要的是乐高。

五、常见坑:编译器最爱拦的四件事Compile 最爱报红的四件事

▢ 坑一:重写忘加 <override>在子类里写了个和父类同名的方法,编译器报「成员已在父类中定义」Compile 会报红,说这个成员父类已经有了——它不知道你是想重写还是手滑重名,必须用 <override> 表态。反过来也一样:给父类里不存在的成员挂 <override>,同样报错。

▢ 坑二:原型里漏掉必填字段。monster{} 看起来很省事,但只要 Name 没有默认值,这行就过不了编译这行就 Compile 不过、直接报红——所有无默认值字段必须在花括号里赋值,一个不能少。

▢ 坑三:试图实例化抽象类。<abstract> 类是纯图纸,enemy_base{} 这种写法直接编译错误,必须实例化它的具体子类。

▢ 坑三:想直接拿抽象类造实例。挂了 <abstract> 的类是纯图纸,写 enemy_base{} 会直接 Compile 报红——抽象蓝图只能当爹,你得改用它的某个具体子类来造。

▢ 坑四:以为赋值会复制对象。类是引用类型:把实例赋给另一个名字,得到的不是复印件,而是同一只怪物的第二个门牌号——

▢ 坑四:以为把对象赋给新名字就复制了一份。蓝图对象是引用——就像蓝图里的 Object Reference 变量:把 A 这个引用赋给 B,并没有复制出第二只怪,B 只是同一只怪的第二块门牌;从 B 改血,A 那边看过去也跟着变——

reference_trap.verse
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() 方法,正确做法是?

拓展阅读

技巧 · EXTRA

(super:) —— Verse 里最容易写错的调用

重写方法之后,怎么把父类的实现也叫回来?一个长得非常怪、但一次学会终身免疫的语法。

进入拓展 →

拔高 · EXTRA

unique:对象身份与 comparable 的秘密

为什么两个实例默认连 = 都不能比?player 又凭什么能当 map 的键?答案是一个 specifier。

进入拓展 →

拓展 · EXTRA

constructor vs 原型:两种造对象的方式

除了花括号直填,Verse 还有 <constructor> 函数——什么时候该用哪个?

进入拓展 →