Godot引擎基础

变量

变量必须以字母或下划线开头,可以由字母、下划线、数字构成变量名:

var a = 1
var b = "Hello"

# 不同类型的变量可以相互赋值
b=a

# 指定变量类型,基本变量类型有int、float、String、bool,指定后相互赋值前需先转换
var a :int = 1
var b :float = 2.6
var c :String = "Hello"
var d :bool = true

不同类型变量的转换

# float转为int会取整数部分
a=b
print(a) #输出2

# int或float转为String
c=String.num_int64(a) #1
c=String.num(b) #2.6
c=c+c #2.62.6

# bool不能转为String,非0数字为true,0为false
d=a #true
d=!a #取反,false
d=0 #false

数组

# 数组
var a :Array = [1, 2, 1.2, "string", true]
# 指定数组期待的元素类型
var b :Array[int] = [2, 3, 4, 5, 4]

# 数组下标从0开始
print(b[1]) #3

# 增删数组的元素
b.append(6) #在最后添加6,变为[2, 3, 4, 5, 4, 6]
b.erase(4) #删除数组中出现的第一个的4,变为[2, 3, 5, 4, 6]

print(b) #打印数组
print(b.size()) #打印数组的元素个数,输出5


# 数组是引用类型
var c :Array[int] = [2, 3, 4, 5, 6]
var d = c
d.append(4)
print(c) #[2, 3, 4, 5, 6, 4]
print(d) #[2, 3, 4, 5, 6, 4]

字典

var dict = {"a": 0, "b": 1, "c": 2}

函数

函数块通过Tab缩进识别,有Tab的是函数的作用域,无Tab的是当前节点公用的作用域;各个函数都有一个独立的作用域,在一个函数内部定义的变量无法在另一个函数中直接访问变量名。函数运行结束后基本变量会自动销毁,引用类型的变量在被外部引用时函数运行结束后不会自动销毁。

空函数体要加pass,注意pass要缩进。非空的函数体也可以添加pass,只是没有任何作用。

# _enter_tree 是内置虚函数
func _enter_tree():
    var d = add(2,3)
    print(d) #5
    var e = add(1)
    print(e) #11
    var f = add2(3.5, 4)
    print(f) #7

# 指定参数类型,默认参数
func add(a:int, b:int=10):
    var c=a+b
    return

# 让返回值转为int
func add2(a,b)->int:
    var c=a+b
    return

func test()
    pass

内置虚函数

内置虚函数是指没有实际处理流程的函数,节点内的虚函数会在特定的条件下自动被触发,类似安卓的生命周期函数。

常用的虚函数有:

if、while与for

if

每个ifelifelse都有独立的作用域,在其作用域内定义的变量外部的作用域无法访问。whilefor同理。

比较运算符:>>=<<===!=

var a = 1
if (a!=0):
    print("Hello")

var b = 2
if b>3:
    print("b>3")
elif b>2:
    print("b>2")
elif b>1:
    print("b>1") #输出
else:
    print("b<=1")

对于浮点数要注意:

var a = 0.1
var b = 0.2
if (a+b==0.3):
    print("Hello") #不会输出

# 要改为
if (is_equal_approx(a+b, 0.3)):
    print("Hello") #会输出

字符串虽然不能转为bool,但是if可以写成如下形式:

var a:String = "test"
if a:
    print("Hello") #会输出

var b:String = ""
if b:
    print("Hello") #空字符串代表false,所以这里不会输出

var c:String = "123"
if c=="123":
    print("Hello") #会输出

while

var a = 0
while a<10:
    a+=1
    print(a)
    if a == 5:
        break

for

# 遍历数组
var a :Array[int] = [2, 3, 4, 5]
for i in a:
    print(i)

# 遍历字典
var dict = {"a": 0, "b": 1, "c": 2}
for key in dict:
    print(dict[key])

# 遍历字符串
for char in "Hello":
    print(char)

单例

单例是一个可以在任意脚本中对其进行访问的对象,Godot内置了很多单例,主要成员是各类Server;我们也可以自定义单例,但自定义单例必须是节点类型的对象,是开发者自定义的全局对象。每个单例都是独一无二的对象。

内置单例

比如Input单例,他可以对玩家的按键进行反馈。游戏中的按键通过如下方法设置:项目->项目设置->输入映射->添加新动作(输入动作名字)->右侧”+“号->设置对应键位。然后就可以在代码中通过Input单例来获取:

func _process(delta: float) -> void:
    # 比如设置了一个名为"left"的动作,通过Input.get_action_strength可以获取按下对应键的力度,是0-1的值,若是键盘按键则只会是0或者1
    if Input.get_action_strength("left"):
        self.position.x = self.position.x - 1 #其中self可以省略

再比如ProjectSettings单例,可以使用代码在这个单例中设置项目设置的信息,并保存为project.godotoverride.cfg

自定义单例

先新建脚本,然后:项目->项目设置->全局(或者叫Autoload)->路径(选择刚刚创建的脚本)->修改节点名称->添加。然后就可以在刚刚创建的脚本中定义函数,并在任意脚本中通过节点名称.函数名调用对应函数。

Node与场景

节点(Node)是Godot中最基本最常用的开发组件。

Node的获取

# 比如有一个声音节点名字为audio(双击左侧节点栏中对应的节点可以改名)
var soud = get_node("audio")
soud.play()

# get_node()也可以简写为 $节点名称 ,比如这里可以简写为
$soud.play()


# 获取子节点,比如第2个子节点
get_child(1)
# 获取所有直接子节点
get_children()
# 获取多级子节点(类似目录的形式)
get_node("node1/node2")

# 获取父节点
get_parent()
get_node("..")

补充:为节点添加导出属性

# 添加导出属性,在右侧检查器中可以快速调整该属性
@export var x = 5

场景

Godot是以场景->节点管理内容的,每个场景(比如人物场景、地图场景等)下有多个子节点、及子节点的子节点等,要运行脚本,就要为节点附加脚本。场景文件是一组节点的集合,是节点加载和存储的基本单位。Godot通过Server在节点与渲染引擎之间沟通。

一般来说只会在场景的根节点附加脚本,避免脚本管理混乱。

加载场景并添加到场景树的代码如下:

func _ready():
    var scene_resource = load("res://sprite.tscn") #加载场景资源
    var root_node = scene_resource.instantiate() #生成节点集合
    self.add_child(root_node)

场景实例化的过程:①资源文件->②经过load()转换为资源对象->③调用instantiate()实例化->④生成各个节点并调用_init(),此时脚本中定义的函数外部变量也首次出现->⑤节点建立起位置和父子关系->⑥经过add_child将节点集合加入场景树⑦从根节点自上而下开始执行_enter_tree()->⑧自下而上开始执行_ready()->⑨节点开始受场景树管控.

也就是说,在执行_init()时,节点间的层级关系还没构建,此时若在_init()中使用get_child()等方法是无法获取到其他节点的。

在函数外部使用 @onready 来修饰变量,可将变量的赋值拖延到 _ready() 执行的时刻。

var a = get_child(0)
@onready var b = get_child(0)

func _init():
    print("_init-start")
    print(get_child(0)) #<Object#null>
    print(a) #<Object#null>
    print(b) #<null>
    print("_init-end")

func _ready():
    print("_ready-start")
    print(get_child(0)) #可以获取到对象
    print(a) #<Object#null>
    print(b) #可以获取到对象
    print("_ready-end")

节点的owner属性owner是一个节点类型的变量,用来表示某个节点。在一个场景文件实例化所产生的节点集合中所有节点的owner属性都指向这次实例化生成的根节点。

使用代码生成场景并设置owner

extends Node

func _ready():
    var root_node = Node.new()
    var child_node1 = Node.new()
    root_node.name = "aaa"
    child_node1.name = "bbb"
    root_node.add_child(child_node1)
    # owner指向根节点
    child_node1.owner = root_node

    # 使用场景资源对象的相关函数保存场景
    var scene_pack :PackedScene = PackedScene.new()
    # 打包场景时只打包场景的根节点
    scene_pack.pack(root_node)
    # 使用ResourceSaver单例相关函数导出到项目文件
    ResourceSaver.save(scene_pack, "res://aaa.tscn")

MainLoop与SceneTree

场景树(SceneTree)是游戏的管理者,它负责Godot的内置服务器与节点的沟通。内置服务是Godot的各种模块,包括计时系统、物理模拟系统、图像绘制系统等。

场景树继承自主循环(MainLoop)类,场景树是在主循环的基础上,对节点管理进行了扩写。虽然存在主循环类,但游戏运行时只存在场景树对象。

程序启动后,程序会创建一个主循环对象(一般是场景树),它包含了初始化、空闲帧同步回调、物理帧同步回调等方法。这个类中不包含节点相关的具体操作。

物理处理(Physic Process):Godot游戏程序中会内置一个物理服务器,用于处理游戏世界内的各种物理运算,在每一次物理运算前,这个服务器都会给予主循环一次参与计算的机会,这就是主循环中的物理处理。默认情况下,物理服务器一秒内会进行60次运算,因此主循环中的物理处理也会每秒进行60次处理。

空闲处理(Idle Process):在Godot游戏程序相对”空闲”,即某些内置服务器运行结束的时候,执行此处理。引擎会尽可能快的利用空闲时间来绘制新的游戏图像。

相关函数:

涉及到删除对象的代码,都必须要在空闲帧中执行。

场景树的功能

使用场景树可以命令同组节点调用函数或修改属性:

# 分组的操作
self.add_to_group("group1")
self.remove_from_group("group1")
self.is_in_group("group1")

# 获取场景树
get_tree()
# 获取组内的所有节点
var nodes :Array[Node] = get_tree().get_nodes_in_group("group1")
# 命令同一组内节点调用函数
get_tree().call_group("group1", "test_func") #假设分组group1中的节点都有test_func函数

游戏暂停,当场景树进入暂停后,节点会根据自己的暂停模式来调节自己的状态,并且pausedprocess_mode满足条件时不再调用_process()_physic_process_input()等虚函数:

# 场景树暂停
get_tree().paused = true
# 设置当前节点的暂停模式
self.process_mode = Node.PROCESS_MODE_PAUSABLE

信号与await

使用get_node获取节点后,如果要调用的节点函数不存在,程序会崩溃;而使用信号调用函数,如果双方对象或目标方法不存在,程序只会警告,而不会崩溃。

信号是指将一个对象的”信号”与另一个对象的”函数”绑定,信号发射后程序会进行函数的调用。这样可以保证每个场景都能单独运行,方便单独测试。信号有内置信号与自定义信号,一个节点所有的信号可以在右侧节点处查看,右键选择信号名可以选择链接并选取要绑定的函数,也可以通过代码去绑定。

自定义信号(信号的作用域只在对象内部,其他对象无法访问):

# 定义一个函数
func test_function(p):
    print(p)

# 定义一个信号
signal test_signal

func _ready() -> void:
    # 进行绑定,将test_signal信号绑定到当前对象的test_function函数中
    self.connect("test_signal", Callable(self, "test_function"))

    # 发射信号
    emit_signal("test_signal", "Hello world")

await可以使某个函数暂停运行,直至接收到了来自某个对象的信号:

func _ready() -> void:
    # 暂停2秒
    var time = get_tree().create_timer(2)
    await time.timeout

类与继承

我们称拥有自己属性、函数与信号的数据单位为对象,比如节点,它有内置属性、内置函数,也可以通过脚本的形式添加属性或函数。我们可以称具有相同、函数与信号的对象们为一个类。

继承分为:

Object中常用的函数

# 初始化的虚函数,当一个对象生成时,此函数会被自动调用
func _init() -> void:
    pass


# 从程序中删除此对象,但不能直接使用
# self.free()
# 应该改为在空闲帧中删除
self.call_deferred("free") # call或call_deferred都是Object的函数
# 效果和如下相同,但该函数不是Object的函数
self.queue_free()


# 接收通知虚函数,比如节点即将被销毁前会调用,此时 what=1
func _notification(what: int) -> void:
    if what == 1:
        print("节点即将被销毁")


# 为对象设置脚本,大部分对象都可以设置脚本
var scr = load("res://script.gd")
$node.set_script(scr)
$node.test() #设置好后就可以调用脚本内的函数了

继承

定义一个类:

class_name A
extends Object
    func test():
        pass

另一个类继承:

# extends "res://A.gd"
# 或者
extends A
    func test():
        pass

    func _ready() -> void:
        # 调用自己的函数
        test()
        # 调用父类的函数
        super.test()

创建对应的实例:

var a :A = A.new()

# 继承自Object的类的实例就像一个游戏物体,不会自动删除释放,需要手动释放内存
a.free()

内部类

我们通过class_name定义的是全局类,全局类在Godot整个项目内都可以通过代码实例化。

我们可以通过class关键字来定义一个内部类,内部类只有当前脚本才可以实例化。内部类默认继承自Object,也可以通过extends关键字指定要继承自哪个类。内部类的作用域和脚本不同,是两个独立的作用域,因此内部类无法访问脚本中的全局变量。

静态变量和静态方法

静态变量是在程序运行期间,保持其存在和值的变量,静态变量用static关键字修饰:

static var a :int = 0

类似的,有静态方法,静态方法只能访问静态变量。

我们可以直接通过类名访问静态变量和静态方法:

func _ready() -> void:
    print(AAA.name)
    AAA.test_static()

class AAA:
    extends RefCounted

    static var name :String = "name"
    static func test_static():
        print("test")

RefCounted

一般来说,继承自Object的类要手动删除,否则会一直留在内存里。

RefCounted继承自Object,但它内置了一个独特的引用计数器,当引用计数器归0时,此对象会自动被程序删除。它的优点在于不包含任何内置属性,仅有四个内置函数,也不必像节点那样要频繁地受到场景树的控制参与服务器的计算,相比节点更加轻量,并且还能进行内存的自动管理。

RefCounted对象的生成可以通过 类名.new()load("路径").new()preload("路径").new() .

例如,定义一个类继承自RefCounted

class_name person
extends RefCounted

var name:String = ""
var age:int = 0

# 通过 _init 的参数,指定 new 时要传入的参数
func _init(p_name) -> void:
    name = p_name
    print(name + "被初始化")

func one_year_past():
    self.age += 1

func _notification(what: int) -> void:
    if what == 1 :
        print(name + String.num_int64(age) + "被销毁")

创建对应的实例:

var person1

func _ready() -> void:
    person1 = person.new("张三")
    # 或者
    var person2 = load("res://person.gd").new("李四")

    person2.one_year_past()

输出结果如下,可以看到,张三并没有被销毁:

张三被初始化
李四被初始化
李四1被销毁

常用的RefCounted子类

Resource

将资源文件转为Resource有几种方法:

方法一:

1、将资源文件拖入到项目中;

2、编辑器对资源文件进行导入,生成特殊文件及其资源文件对应的import文件;

3、游戏启动后借由import文件加载特殊文件生成资源对象;

方法二:

使用 load("路径")preload("路径").

通过这种方法加载的资源,如果加载的文件已经被转化为资源,且此资源引用计数器不为0,则再次加载该文件不会产生新的资源对象,而是返回一个原先资源对象的引用,此时任意一处对该资源的修改,都将影响所有使用此资源的对象。

loadpreload的区别:load的参数可以是字符串变量,而preload则无法使用变量。脚本文件转换为脚本资源时,转换程序会自动翻找文件中是否出现了preload函数,若出现,则在脚本资源转换的同时进行preload资源的加载,这时变量还没有出现,因此只能以文本字符串的形式来告知preload加载的内容。

方法三:

通过 类名.new() 创建,通过这种方法加载的资源不会产生相同的引用。某些文件也可以通过内置资源的代码进行加载。

extends Sprite2D

func _ready():
    var img = Image.new()
    img.load("res://icon.svg")
    var tex = ImageTexture.create_from_image(img)
    self.texture = tex

常用Node

常用节点有:

Viewport

Viewport节点是游戏运行时出现的第一个节点,当场景树第一次加载主场景时,游戏程序会先创建一个viewport节点并将此节点添加到场景树下,然后再将主场景的根节点作为viewport的子节点添加过去。也就是说,场景树下的节点都是第一个viewport节点的子节点。(在新版本已经改为Window节点作为根节点,WindowViewport的子节点,所以基本用法是一样的)

Viewport节点可以在屏幕中创建一个不同的窗口或在另一个窗口中创建子窗口。Viewport节点的Camera2D/Camera(2D摄像机/3D摄像机)子节点,可以调整游戏的显示区域,也会改变游戏世界中监听游戏音效的位置坐标。常见应用有:双人游戏的分屏效果(将两个subviewportworld_2d属性设置为同一个,再通过设置不同的Camera2D节点来显示不同的位置)、获取屏幕画面截图、获取鼠标位置、输入事件的处理等。

输入事件的处理(最常用功能)

_input_unhandled_input都是输入事件处理函数,一个输入时间被创建时,会在节点中进行传播,此时节点中的_input函数将根据传播的顺序依次被调用。如果在_input传播中,输入时间未被处理掉,则在节点中再进行一次此事件的传播,此时_unhandled_input自动被调用。

比如,判断鼠标左键按下:

func _input(event: InputEvent) -> void:
    if event is InputEventMouseButton :
        if event.button_index == MOUSE_BUTTON_LEFT :
            if event.pressed == true :
                print("鼠标左键1")
                # 这里要启用SubViewportContainer的Stretch属性,才能使它的SubViewport子节点占满,然后下面的代码才能生效
                $SubViewportContainer/SubViewport.set_input_as_handled()

func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton :
        if event.button_index == MOUSE_BUTTON_LEFT :
            if event.pressed == true :
                print("鼠标左键2")

比如,创建鼠标点击事件:

func _ready() -> void:
    var event = InputEventMouseButton.new()
    event.button_index = MOUSE_BUTTON_LEFT
    event.pressed = true
    $SubViewportContainer/SubViewport.push_input(event)

PhysicsBody2D和Area2D

PhysicsBody2DArea2D:用于制作在游戏中可以被感知的,且占据一定空间体积的物理对象。Body类节点主要用于制作游戏中的物理对象,而Area节点主要用于检测感知这些物理对象、施加额外的物理属性、修改区域内的音轨情况等。它们两者均继承自CollisionObject2DCollisionObject2D节点是2D世界中物理对象的基类,他可以容纳任意数量的2D碰撞形状,这些2D形状用于定义Body代表的实体形状和Area节点所规定的范围。

常用的PhysicsBody2D类:

CollisionObject2DLayer属性代表节点在第几层,而Mask属性表示当前节点可以和第几层的节点发生碰撞。节点可以同时在不同的层,也可以同时和不同层的节点发生碰撞。LayerMask的id都是2的指数。

要使Body类和Area类节点可以发生碰撞,需添加子节点比如CollisionShape2DCollisionPolygon2D.

碰撞检测

碰撞检测代码如下(要被检测碰撞的物体可以通过self.add_to_group("group_collision")来设置对应分组,分组名是自定义的),主要是通过get_overlapping_areas()get_overlapping_bodies()函数进行检测:

# 检测碰撞的代码要放在物理处理的虚函数中
func _physics_process(delta: float) -> void:
    for i in get_overlapping_areas():
        if i.is_in_group("group_collision"):
            i.position.x = i.position.x + 1

对应的节点结构如下:

|--Area2D
| |--Sprite2D (用于显示图片)
| |--CollisionShape2D (用于设置碰撞区域,一般是把图片的范围包裹住)

Control

Control是所有界面类节点的父节点。Control类节点提供了输入处理(_gui_input)、拖拽、鼠标移动、语言翻译转换、快捷切换界面节点、主题(Theme)、界面提示文本等相关的属性与虚函数。

# _gui_input函数的事件与Control类节点的范围绑定,只有当比如鼠标移动到Control类节点范围内才会触发_gui_input函数
# 此外,_gui_input函数还与Control类节点的可见性、层次关系(渲染顺序)等绑定
# 可在右侧检查器中找到Control的Mouse选项,把Filter设置为Stop,这样上层的Control类节点就会截断事件,下层重叠的Control类节点就不会触发_gui_input函数
func _gui_input(event: InputEvent) -> void:
    print(event)

常用的Control类节点: