Skip to content

类是一种用来组织代码的数据结构,它可以包含:

  • 数据成员(如变量和常量)

  • 函数成员(如方法、属性、构造函数、析构函数等)

  • 嵌套类型(类中套类)

它是构造对象的蓝图,定义了某种对象的属性和行为

类的声明

在GDScript中你可以声明三种类:

  • 无名类:GDScript中类的默认形式,要在其他地方使用该类不够方便

  • 具名类(全局类):使用 class_name 注册为全局,可以在任何脚本直接访问的唯一的类

  • 内部类:类内部的类,与无名和具名类具有不同的作用域

无名类

在 GDScript 中,每个脚本文件本身就是一个类,默认是“无名”的。

所以一般写脚本时不需要特别声明类名,但这样有个小麻烦:你无法直接通过类名来引用它,而是必须使用脚本路径进行加载与实例化。

举个例子: 假如你有一个脚本叫 character.gd,你想在其他地方使用它,要这样写:

gdscript
# 继承 'character.gd'.
extends "res://path/to/character.gd"

# 加载脚本资源,创建该类的实例
var Character = load("res://path/to/character.gd")
var character_node = Character.new()

具名类

使用 class_name 关键字可以给类起个名字,并注册为全局类,这样在任何地方你都可以直接用这个名字来访问类,不用再写路径了!

比如你把 character.gd 改成这样:

在GDScript中,类名的标识符一般使用大帕斯卡命名法,即标识符的每个单词首字母大写

gdscript
# 'character.gd'.

class_name Character


var health = 5


func print_health():
    print(health)

这样你就可以这样使用它:

gdscript
# 继承它
extends Chararcter
# 创建它的一个实例
var c = Character.new()

而且还可以从 get_script()ResourceLoader 和类名直接访问同一个资源,说明它们是同一个脚本引用:

gdscript
print(get_script())
print(ResourceLoader.load("res://character.gd"))
print(Character)

内部类

你也可以在类中使用class关键字定义一个类,我们称之为“内部类”。它可以嵌套在另一个类中,组织结构更清晰

内部类也允许嵌套内部类

gdscript
class_name MyClass

class MyInnerClass:
    var a = 5
    
    func print_a():
        print(a)

访问内部类有两种方式,取决于在哪个地方使用:

  • 在当前文件中访问: MyInnerClass.new()

  • 在其他脚本中访问: MyClass.MyInnerClass.new()

类的成员

类中可以包含字段(变量)属性方法(函数成员)以及信号等内容,统称为类的成员。

在 GDScript 中,我们通常用下划线 _ 前缀来标记不希望被外部访问的成员,例如 _my_property_my_function,以示“内部使用”或“私有”。

字段(Field)

字段是类中最基础的变量形式,没有封装行为,外部可以直接读取或修改,因此通常只用于类的内部逻辑。

gdscript
class_name Player

const max_health = 1000
var health = 100
var speed = 100

在上例中,healthspeed 是字段,没有访问控制。const 常量也是一种字段,只不过是只读的

属性(Property)

属性是通过 get / set 方法控制访问的数据成员。它是对字段的封装,可以用来添加验证逻辑、触发信号,或者只读访问等。

gdscript
class_name Player

signal hp_changed(old_value: int, new_value: int)
const max_health = 9999

var health: int = 100:
    get:
        return health
    set(value):
        if value == health:
            return
            
        if value > max_health:
            value = max_health
        elif value < 0:
            value = 0

        var old_val = health
        health = value
        hp_changed.emit(old_val, health)
            
var speed: int:
    get:
        return _speed
        
var _speed: int = 100

可以看到,我们在healthset 中对数值进行了范围限制,并发射了 hp_changed 信号,在speedget中令它始终返回_speed,这使其成为了一个只读属性

有一点需要特殊说明,在 GDScript 中,即使字段(如 var my_var = 123)没有显式声明 getter/setter,也仍然被称为“属性” 。这是因为在 GDScript 中,“属性”这个概念被广义地用于表示任何类中的成员变量,无论它是否封装了访问逻辑。

和传统的 OOP 比起来,这里“属性”更像是一个 语义标签,而不是访问机制上的明确区分。也就是说:

  • 传统 OOP 语言(比如 C#、Java):字段和属性是严格区分的,属性通常代表有访问器封装的字段。

  • GDScript 中:字段默认就属于属性范畴了,只是没有自定义访问逻辑而已

属性访问器

属性的 getset 可以采用匿名具名方式

gdscript
# 匿名
var my_prop:
    get:
        return ...
    set(value):
        ...

# 具名
var my_prop:
    get = get_my_prop, set = set_my_prop
    
var my_prop: get = get_my_prop, set = set_my_prop # 允许写在同一行

func get_my_prop():
    return ...
    
func set_my_prop(value):
    ...

这里使用了显式声明的getter/setter成员函数来作为属性访问器,同时,它们也必须要具备和匿名属性访问器相同的签名

  • getter: 不接收参数,返回值为获取到的对应属性的值,其类型为属性的类型

  • setter:无返回值,接收一个对应属性类型的参数,表示要设置的新值

在访问器内部使用自身属性时,不会递归!这点 GDScript 非常贴心~但是注意,如果你在访问器中调用了另一个函数,而那个函数又访问了同一个属性,就会出现无限递归

gdscript
# 不会导致无限递归
signal changed(new_value)
var warns_when_changed = "some value":
    get:
        return warns_when_changed
    set(value):
        changed.emit(value)
        warns_when_changed = value
        
var my_prop: set = set_my_prop
func set_my_prop(value):
    my_prop = value
    
# 在访问器中调用其他函数,而该函数访问该属性,会导致无限递归
var my_prop:
    set(value):
        set_my_prop(value)
        
func set_my_prop(value):
    my_prop = value

构造函数(Constructor)

构造函数,又名构造器(Constructor)是类创建实例时自动调用的特殊函数。它可以用于初始化属性、设置状态等。

默认情况下,每个类都有一个无参构造函数,也可以显式声明类的构造函数

类的构造函数名为_init,你可以随意更改其所需的参数

但注意:如果_init中定义了必填参数则该类只能使用代码来实例化,其他任何方式(例如PackedScene.instantiate()Node.duplicate())创建时,该类的初始化都将失败,因为Godot无法为其填充所需的参数

编写了具有参数的构造函数的脚本,不能挂载到节点上,如果是自定义资源,也不能用ResourceLoader来加载

gdscript
class_name Item

var name: String

func _init(item_name: String) -> void:
    name = item_name

静态构造函数(Static Constructor)

静态构造函数_static_init会在类第一次被加载或其静态变量初始化后执行一次,一般用于初始化静态字段。

与实例构造函数不同,静态构造函数不能添加参数,静态构造函数会先于实例构造函数执行

gdscript
static var my_static_var = 1

static func _static_init():
        my_static_var = 2

析构函数(Destructor)

析构函数(Destructor),又称析构器、终结器,它会在对象即将被销毁时调用,做最后的清除工作。

GDScript 没有传统意义的析构函数,但可以使用 _notification 接收生命周期通知。对象即将被销毁时会收到 NOTIFICATION_PREDELETE 通知,这时就可以做清理工作

gdscript
func _notification(what: int) -> void:
    if what == NOTIFICATION_PREDELETE:
        print("再见")

方法(Method)

方法用来定义类可用的行为,方法其实就是我们之前所学的函数,现在只是换了一个更“面向对象”的名字,现在让我们来重新认识一下它

声明方法的格式如下

gdscript
func 方法名(参数列表) -> 返回值类型:
    # 方法具体实现

返回值用于定义方法返回数据的类型,如果不需要返回一个值则使用void关键字,如果定义的方法有返回值,则必须使用return关键字在所有的代码执行路径返回一个指定类型的数据。

返回值类型在声明方法时可以省略,此时方法的返回值类型为Variant

调用方法时可以给该方法传递对应数量的值,传给方法的值叫作实参,即方法参数的实际值,在方法内部接收实参的变量叫作形参

形参在紧跟着方法名的括号中声明,方法可以有零或多个形参,形参的定义和变量类似,但不需要写 var,多个参数之间用英文逗号隔开。

形参只在方法内部有效,方法的参数始终按值传递,调用方法传参时,会将实参的值复制到形参中,因此在方法内部对参数的修改不会影响到外部传入的值。

在 GDScript 中,方法可以接受三种类型的参数,分别是:

1. 必选参数(Required Parameters)

这是最基本的参数类型,调用方法时必须传入值

gdscript
func foo(a: int):
    pass

2. 可选参数(Optional Parameters)

只要给参数一个默认值,它就变成可选的

可选参数必须放在必选参数之后(否则会语法错误),可以根据需要传或不传

gdscript
func foo(a: int, b: int = 0):
    pass

3. 剩余参数 / 可变参数(Rest Parameters)(Godot 4.5+ 新特性)

当你想要一个方法能接受任意数量的额外参数时,就可以使用剩余参数 它的类型必须是 Array,写法是在参数前加三个点(...),而且必须放在参数列表的最后一位!

gdscript
func foo(a: int, b: int = 0, ...args: Array):
    for arg in args:
        print(arg)
gdscript
foo(1, 2, "hello", 42, true) 
# args 就会是 ["hello", 42, true]

信号(Signal)

信号是类的一种成员,用于定义“事件”。你可以将它理解为“这个类可以发出的某种通知”。

我们之前已经在信号章节详细讲过信号的使用和定义方式,这里就不赘述了

常量成员(Constant Member)

类中还可以包含一些不可变的常量数据,它们是类的静态特征之一,通常有三种形式:

  1. 常量(Constant):使用 const 定义,是类的字段,值在声明时设定后就不能再改变。

  2. 枚举(Enum):使用 enum 定义的值集合,在 GDScript 中属于类的常量成员,可用作类型,也可当作只读字典使用。

  3. 内部类(Inner Class):使用 class 定义在类内部的类,本质上也被当作一种类的常量成员。

GDScript中类的本质

在 Godot 中,我们通常使用“类”这个术语来描述可复用的对象模板。但和传统编程语言不同,Godot 引擎底层并没有真正以我们熟悉的那种方式定义类,而是提供了两种主要的可复用结构:

脚本(Script)

脚本其实本质上是一种资源,而非传统意义上的“类”。它的作用是告诉引擎:在某个已有的内置类(比如 Node、Control 等)上,添加一些自定义的行为和属性。

当你创建一个脚本时,Godot 会将其注册进一个叫做 ClassDB 的类数据库中,这个数据库在运行时负责保存并提供类的信息,包括:

  • 属性

  • 方法

  • 常量

  • 信号

当游戏运行中访问对象的属性或调用方法时,Godot 就会通过 ClassDB 来判断该操作是否被支持。 简单来说,脚本就是 ClassDB 中为某个对象“扩展”的部分,它定义了这个对象的额外行为。

所以我们虽然说写了一个“类”,但其实我们写的是“在某个 Godot 类型上添加行为的脚本”。

场景(Scene)

虽然场景看上去和脚本很不一样,但其实也可以被看作是一种“类”

场景是一组组织好的节点,可以被复用、实例化、继承。这就像是在写一个类,其中“属性”就是各个子节点,“行为”则由脚本定义。

通常我们会在场景的根节点上挂载一个脚本,然后这个脚本就可以操作这个场景的所有子节点,定义他们的交互、状态等等。 在这种结构中,脚本通过命令式逻辑,为整个场景添加了行为,而场景本身也成了类的一部分。

场景的内容决定了:

  • 你可以访问哪些节点

  • 节点之间如何组织

  • 它们是如何初始化的

  • 它们之间有哪些信号连接

因此,许多面向对象编程的原则同样适用于场景的设计,比如:

  • 单一职责

  • 封装

  • 重用

你可以将一个场景类比为一个“类 + 实例化模板”的组合体,它既包括了对象的结构,也可以附加行为(脚本),非常灵活又高效

练习

  1. 定义一个Dog类,包含nameage字段,一个方法 bark(),会输出 "汪汪!我是[name]!"

  2. 扩展上题的 Dog 类,现在要求:

    • age 字段设为私有(不希望让外部访问)

    • 提供一个方法 set_age(value) 来设置年龄

    • 提供一个方法 get_age() 来获取年龄