跳转至

第四章:GDScript编程基础与实践

本章导言

在前几章中,我们逐步认识了游戏的构成、Godot 的基本概念,以及如何使用编辑器创建项目、搭建场景和管理资源。你已经掌握了用编辑器界面“拼装”游戏的能力,现在,是时候赋予你的游戏真正的“生命”了。

在本章中,我们将走进 Godot 的脚本世界,学习使用它专属的编程语言,GDScript。这是连接玩家操作与游戏响应的关键桥梁,让角色可以动起来、场景可以变化、游戏可以与人互动。

你将学会如何:

  • 使用 GDScript 为节点添加逻辑功能;
  • 理解变量、条件语句、函数、数组等基本编程概念;
  • 响应用户输入,控制角色行为;
  • 利用信号与组实现节点之间的协作;
  • 建立面向对象的编程思维,组织更复杂的游戏结构。

不用担心你是否学过编程,我们会从最基础的概念讲起,用可视化的操作和有趣的例子引导你一步步掌握编程的魔法。

1.什么是GDScript

在 Godot 中,我们不仅需要使用各种节点来搭建场景,还需要用“语言”或“指令”告诉游戏每一个角色该做什么、什么时候做。这种语言就是 GDScript,它是Godot 专门为游戏开发设计的脚本语言。

GDScript 是一种面向对象的脚本语言,由 Godot 引擎团队专门开发,用来快速而高效地控制游戏中的一切逻辑行为。你可以用它来让角色移动、检测碰撞、播放动画、处理输入,甚至控制整个游戏流程。虽然它是一种编程语言,但它的语法非常简洁,尤其适合初学者学习。如果你学过一点 Python,你会觉得它很熟悉。即使没有任何编程经验,也能很快上手。

你可能会想,我们不是已经可以用 Godot 拖拽节点、设置属性了吗?为什么还需要写代码呢?我们可以把游戏比作一场舞台剧:

  • 场景搭建、角色布置,就像舞台背景和演员位置,这些任务可以用编辑器来完成;
  • 但如果你想让角色在玩家点击时跳起来、走到某个位置后播放动画、被敌人碰撞时扣血,这些在游戏运行时会自动发生的“行为逻辑”就必须用脚本语言来描述。

也就是说,脚本是用来告诉游戏“什么时候做什么事”的语言。没有脚本,游戏世界就像静止的木偶舞台;有了脚本,它才会像真正的舞台剧一样,拥有动作、反应和变化。

Godot 也支持其他语言,比如 C#、C++。那为什么推荐初学者用 GDScript 呢?原因如下:

  • 专为 Godot 设计:它和 Godot 的“节点系统”“信号机制”等功能无缝衔接。
  • 语法简洁清晰:语法风格类似Python,没有复杂的符号,不需要写花括号,只需要靠缩进来组织代码。
  • 上手速度快:初学者可以用最少的代码实现丰富的功能。
  • 性能优秀:虽然是脚本语言,但足以胜任大多数游戏开发任务。
  • 集成体验佳:Godot 编辑器内置 GDScript 支持,写代码时会自动补全,极大提升开发效率。

2.节点中挂载脚本

在 Godot 编辑器中,我们通过“拖动节点”、“添加子节点”等方式,搭建好了游戏世界的“骨架”。但这些节点默认是静态的:不会自动移动、不会响应玩家输入,更不会自己判断状态。想让游戏角色“动起来”,你需要给它一个“大脑”,这就是代码脚本的作用。

在 Godot 的项目中,大部分脚本都是“挂载”在节点上的。这是一种非常自然的写法,就像给钢铁侠穿上战衣,让他能飞天、射击、应对战斗。所以挂载脚本就是给节点扩展增强能力,让它成为一个会思考、有反应的角色。

2.1 挂载脚本的操作步骤

创建新场景

像第二章一样,我们新建一个项目,切换到2D工作空间。在项目中仍然使用Node2D作为场景的根节点,再将它的名字改成Player,最后保存这个场景为player.tscn。此时Player节点代表玩家控制的主角。

挂载脚本

选中 Player 节点,观察左侧场景面板顶部,在加号和锁链标志的右边是搜索区。搜索区右侧,你会看到一个文档图标,还带着绿色的小加号,鼠标悬停上会提示:“Attach a new or exiting script to the selected node”。点击这个按钮,会弹出一个“附加脚本”对话框。让我们一起理解这个窗口中的几个重要选项:

alt text

选项名称 含义与建议
Language 使用的编程语言,默认为 GDScript,保持不变即可
Inherits 表示这个脚本继承的类型,自动设置为你当前选中的节点类型(例如 Node2D)
Template 是否使用默认模板代码,建议勾选,这样会自动生成基础结构
Path/Script Name 脚本的保存路径和名称,通常与节点同名,自动生成即可

Inherits它表示这个脚本所继承的节点类型,因为我们的Player节点是Node2D,所以这里会默认为Node2D,意思是说我们的代码脚本会继承Node2D这个节点类型,在原本节点的基础上进行功能的扩展。

最后,在对话框中点击create,这样就创建了player.gd文件。gd是脚本文件后缀,是GDScript的缩写。

打开脚本文件

Godot会自动打开创建的脚本文件,此时主窗口也会从2D世界切换到Script。因为我们勾选了模板,因此脚本文件中已经包含了一些代码,你也会在左下角的文件系统中看到player.gd已经存在了。

2.2 理解脚本的结构

alt text 模板生成的代码如上图所示,让我们来理解一下这几行代码

第1行的extends Node2D意思是这个代码继承自Node2D类型。后面的代码通过func关键字定义了两个函数,函数是由代码组成的功能模块,可以让代码更加简洁,可以重复使用。

在 Godot 中,我们通过编写函数来控制游戏中角色的行为。而 _ready()_process() 是两种最常用的内置函数,它们会在特定时机被引擎自动调用,不需要你手动触发。

它们就像舞台剧中的“上场”和“每帧演出”,_ready() 函数在节点加入到场景树后触发一次,而_process()函数每帧都会被调用,另外我们后面还会用到_physics_process函数,它和_process函数类似,区别则是以固定的频率被调用,通常是60次每秒。如果你在处理碰撞等物理逻辑,请务必使用_physics_process。它能保证物理模拟的稳定性。

函数名称 触发时机 用来做什么
_ready() 当节点刚刚加入场景树并准备好后调用一次 做一次性的初始化,例如设置初始位置、连接信号、打印欢迎语
_process(delta) 每一帧都会被自动调用(帧率决定频率) 用于执行持续性的逻辑,比如移动、旋转、检测输入(非物理相关)
_physics_process(delta) 以固定频率调用(默认每秒60次),与物理引擎同步 用于执行涉及物理的逻辑,如速度变化、重力计算、碰撞检测、移动等

目前这两个函数中目前只有一行代码,那就是pass,意思是什么也不做。我们来修改一下这个函数,将它修改为如下的代码:

func _ready():    
    print("hello world")
然后运行当前的player场景,游戏窗口上是一片空白,不过你会看到控制台输出hello world

alt text

挂载的脚本文件继承自Node2D类型,也就是说,Node2D节点的所有属性和功能,我们都可以在脚本代码中直接使用。点击Player节点,在Inspector面板窗口中,展开Transform区域,将鼠标放到Position这个单词上,你会看到一个属性帮助,它也会提示position是一个可以使用的属性。让我们在代码中将它打印出来看看。修改_ready函数为下面的代码:

func _ready():    
    print(position)

运行当前的player场景,你会看到控制台输出了Player节点的位置信息,那就是(0, 0)了。你可以尝试着移动一下Player节点,再运行场景,你会发现控制台输出的位置信息也会随之变化。同样的,rotation也是一个可以使用的属性,你可以在代码中打印试试看。所有在Inspector面板中可以使用的属性,都可以在代码中使用。

alt text

然后我们来尝试修改_process函数。我们把_ready函数中的代码复制到_process函数中,点击场景运行,这样你会在控制台上看到一直在输出Player的坐标。

func _process(delta):    
    print(position)

alt text

在场景运行的时候,你可以尝试在2D窗口中移动Player节点,移动的方法有两种,一种是通过鼠标选择Player节点后,再选择主工作区上方的工具栏,选择移动工具(Move Node),然后拖动鼠标,就可以移动Player节点。另一种是选择Player节点后,在检查器面板中,点击Transform区域,然后修改Position的值,就可以移动Player节点。

移动后你会发现控制台输出的Player坐标信息也会随之变化。因为_process函数的执行频率是每时每刻。每时每刻是一个粗略的说法,游戏的工作方式和动画片类似,每秒钟会做很多事情,比如处理数据或是渲染产生许多图像,而且是不断循环进行的,这就是我们在第二章介绍过的游戏循环概念,每一次循环的执行称为一帧。游戏运行每一帧,就是_process函数执行一次。

在上一章我们是用了AnimationPlayer节点让图片动起来,下面我们用代码来实现同样的功能。和之前一样,我们给Player节点新增一个Sprite2D子节点,并把icon.svg图标文件放在这个子节点的texture属性上。

修改player.gd中的代码为如下代码:

func _ready() -> void:
    $Sprite2D.position = Vector2(100,0)

func _process(delta: float) -> void:
    rotation = rotation + 0.1
让我们解释一下这些代码的含义,Vector2(100,0)是一个二维向量,它的x值是100,y值是0,在代码中我们把它赋值给了Sprite2D节点的position属性,这样Sprite2D节点的位置就会变成(100,0)。

之所以要这样操作,是因为我们需要让Sprite2D节点围绕Player节点旋转,所以需要让Sprite2D的横坐标位置离远一些,那么在_ready函数中我们把Sprite2D节点的位置变成(100,0)。

我们现在是在Player节点挂载的代码中,按道理说只能控制Player节点的属性,但Godot可以通过引用来控制节点树上的所有节点。

Sprite2D节点是Player节点的子节点,所以在代码中引用子节点的时候,需要在前面加一个$,再加上节点的名字,再加上点号就能获得引用节点的属性。

然后在_process函数中,让Player节点旋转,就是在每一帧去修改rotation属性,因为脚本是附加在Player节点上的,所以可以直接使用相应的属性。同时修改Sprite2D节点的rotation属性,让Sprite2D节点旋转。另外有个小细节需要注意一下。旋转的增加值0.1使用的是弧度单位而非角度单位。

Player场景的代码完成后,像上一章一样,我们新建一个level场景,生成两个Player场景的实例,分别放在level场景的左边和右边,然后运行level场景,我们就会看到两个旋转图标了。

2.3 理解挂载脚本的本质

在之前章节我们学习到,Godot中的节点其实是一个类,而添加到场景中的节点,其实是一个对象。这个对象包含了节点的属性、方法、信号等信息。例如Node2D节点,我们从检查器面板中可以看到它有position、rotation、scale等属性,如果你打开Node2D节点的帮助文档,你会看到所有这些内置的属性和函数方法,它们都可以通过代码进行控制。

挂载在节点上的脚本,其实是创建了这个节点类的“子类”,在继承其原有功能的基础上,添加自己定义的变量、函数和逻辑。

举例来说,Node2D 是一辆有基础功能的汽车,能动、能转弯;编写代码挂上脚本后,就像是给它安装了“自动驾驶系统”;脚本可以直接使用原本的发动机和轮子,不会去改装它们,而是让它“更聪明、更个性化”,变成一辆“智能汽车”。

当我们“挂载脚本”时,Godot 在内部做了很多事情:

  • 创建脚本资源:你写的 .gd 脚本文件会被保存为一种叫做 Script 的资源类型,就像图片是纹理资源、音频是声音资源一样。

  • 指定继承类型:脚本的开头写了 extends Node2D(或其他节点类型),表示这个脚本是对某个类的扩展,意思就是“我想在已有的节点基础上加点新功能”。

  • 设置节点的 script 属性:Godot 会在节点的 script 属性中记录这段脚本的路径,告诉引擎“这个节点不止是 Node2D,它还加装扩展了某些逻辑”。

  • 编译脚本成为类:在游戏运行前,Godot 会将 .gd 文件编译成一个运行时类(Runtime Class),像 Python 或 JavaScript 一样生成一个可用的对象模板。

  • 节点变成你自定义类的实例:当场景加载时,Godot 不再用原始的 Node2D 类,而是把这个节点当作“你定义的新类”的对象来创建和执行。

  • 运行时自动调用函数:Godot 会自动调用你在脚本中写的特殊函数(如 _ready()_process()),因为它已经把脚本变成了这个节点的一部分。

可以这样理解,“挂载脚本”就是你在用脚本语言为角色注入灵魂的那一步。

2.4 何时不挂载脚本

虽然大部分脚本都会挂载在节点上,但也有一些脚本是独立存在、专门负责管理整体逻辑或共享功能的,它们不会挂在具体节点上。

场景 脚本类型 是否挂载
管理游戏状态(开始、暂停、结束) 控制器类(GameManager) 不挂载,作为单例运行
存储全局数据(金币、进度等) 数据脚本 通常作为 AutoLoad
定义多个角色共享的函数和变量 工具脚本、父类脚本 通常作为基类被继承
创建对象工厂、生成敌人、关卡控制器等 逻辑驱动脚本 通常挂载在主控制节点,或独立运行
这类脚本我们会在后续章节内容中遇到。

3.变量与类型提示

在编程中,变量就像是储物箱,用来存放你在游戏中需要用到的各种信息。比如角色的位置、速度、名字、生命值,这些都需要一个“命名的容器”来装起来,这就是变量。

变量不仅可以存数据,还可以让你反复读取、修改、传递信息,是构建一切游戏逻辑的基石。使用变量的好处包括:

  • 代码可读性提高:通过给变量取有意义的名字,可以使代码更容易理解和维护。
  • 代码重用:变量可以多次使用,减少重复输入相同的值,特别是在需要多次引用同一个值的情况下。这不仅节省了输入的时间,还能减少错误。
  • 动态性:变量使得程序可以根据用户的输入或外部条件的变化作出响应,增强了程序的交互性和适应性。

3.1 变量的声明和赋值

在 GDScript 中,变量的声明通常使用 var 关键字,后跟变量名,然后是等号和变量的初始值,例如:

var player_name = "Hero"
var speed = 100

你也可以指定变量的数据类型,例如:

var speed: int = 100
var direction: Vector2 = Vector2(1, 0)

变量名后面的冒号后面跟指定数据类型,这里的 int 表示变量 speed 的数据类型是整数,而 Vector2 表示变量 direction 的数据类型是二维向量。

推荐大家在变量声明时指定数据类型,有很多好处:

  • 让代码更清晰:一眼就知道变量是干什么的;
  • 防止错误:如果你错误地赋值,比如给 float 变量赋字符串,Godot 会报错;
  • 提升性能:类型明确后,Godot 编译器可以提前优化代码;
  • 增强编辑器体验:类型提示会启用代码补全和参数提示,让你写代码更顺手。

3.2 常用数据类型

Godot 内建了很多与游戏相关的类型:

类型名 示例值 用途说明
int 42 整数,常用于生命值、数量等
float 3.14 小数,常用于速度、角度、位置偏移等
bool true / false 布尔值,用于判断状态、开关
String "Hello" 字符串,用于名称、显示文本等
Vector2 Vector2(100, 200) 二维向量,表示坐标、方向、速度等
Array [1, 2, 3] 数组,用于存储一组数据
Dictionary {"hp": 100, "mp": 50} 字典,用于存储键值对信息
Color Color(1, 0, 0) 表示颜色(RGB),如纯红色
NodePath "../Enemy" 节点路径,常用于引用其他节点

3.3 变量的作用域与生命周期

当你定义一个变量时,它并不是“无所不在”的。变量的作用域决定了它在哪些代码范围内可以访问,而变量的生命周期决定了它存活多久、何时被销毁。这两个概念对于管理游戏状态、逻辑控制乃至性能优化都非常关键。

局部变量(Local Variable)

这是最常见的一类变量,它们在函数或代码块中定义,只在当前作用域内有效。函数调用开始时创建,调用结束后销毁。

func _process(delta):
    var temp_speed = 100  # 只在这个函数中有效
    print(temp_speed)

成员变量(脚本级变量)

当你在脚本最上面定义变量时,它就成为这个节点的“成员变量”。例如:

var player_speed: float = 300

这个变量在整个脚本中都能访问,包括其他函数内部,成员变量随着节点对象的创建而初始化,节点销毁时一起销毁。

全局变量(Autoload 单例)

有时你希望某个变量在游戏的所有场景、所有脚本中都能访问,比如游戏进度、玩家数据、设置选项,这时就需要使用 Godot 的 Autoload(自动加载)机制。这种变量整个游戏运行期间一直存在,无论你切换到哪个场景或脚本,都可以访问它。我们会在后续章节中详细讲解这个机制。

3.4 变量修饰符

在 GDScript 中,我们可以使用一些特殊的“变量修饰符”来改变变量的行为。这些修饰符就像是给变量贴上了“说明标签”,让 Godot 编辑器知道它该如何被初始化、显示或处理。

常见的两种变量修饰符如下:

  • @export:让变量可在编辑器中显示和修改,这样不用改代码也能方便调参数。
  • @onready:节点准备好后再赋值,适用于引用其他节点,确认它们都准备就绪了。

使用@export修饰符

@export修饰符直接写在变量的定义前面,如下所示:

@export var max_health: int = 100
这行代码的意思是:

  • 变量 max_health 仍然属于这个节点;
  • 但它会出现在 Godot 编辑器的右侧属性面板中,你可以像设置节点属性一样修改它;
  • 修改的值会被保存进 .tscn 场景文件中,对每个实例都有效。

适用场景:

  • 适合那些你希望在编辑器中可视化并可以修改的变量。
  • 适合存储场景或对象的参数化数据,这些数据通常需要在不同的实例之间进行设置,或者由开发者在编辑器中手动调整。
  • 当变量的值不依赖于脚本的运行或节点的状态,而是用于配置或初始化时,使用 @export是合适的

这种修饰符的应用场景包括:设置角色速度、血量、颜色、名称等可调参数,为不同场景中的实例赋予不同的初始状态。

使用@onready修饰符

@onready修饰符也是直接写在变量的定义前面,如下所示:

@onready var sprite = $Sprite2D
这行代码表示:不要在脚本一开始就尝试获取 $Sprite2D,而是等节点完全加入场景、准备好后再执行赋值。

需要这个修饰符的原因在于:

  • 节点的子节点可能还没初始化完成;
  • 动态加载的场景或懒加载节点可能为空;
  • 可以避免出现 null 错误或访问未准备好的对象。

适用场景:

  • 适合在节点准备好后进行初始化的变量,这些变量通常依赖于节点本身的状态,或者需要在 _ready() 方法中做进一步的处理。
  • 当一个变量的初始化需要访问节点的其他属性、子节点,或者依赖于场景加载后的状态,使用 @onready 更合适。

需要注意的是,@export@onready 不能一起使用。如果你试图这样做的话:

@onready @export var speed = 100  # 错误!

Godot 会报错或行为不稳定。正确做法是将一个变量拆成两个:一个暴露参数,一个内部引用。

变量修饰符让你的代码更灵活、场景更通用、编辑器更友好,是连接“代码世界”和“可视世界”的桥梁。

3.5 变量实操步骤

目前场景中的代码,Player节点的旋转速度是写死固定的,最好是使用变量来表示这些值。下面我们来修改player.gd代码,参考如下的代码:

extends Node2D

var sprite2d_position :Vector2 = Vector2(100,0)
var player_speed :float = 2*PI
var sprite2d_speed :float = 2*PI

func _ready() -> void:
    $Sprite2D.position = sprite2d_position

func _process(delta: float) -> void:
    rotation = rotation + player_speed * delta
    $Sprite2D.rotation += sprite2d_speed * delta

在这个例子中,我们在代码文件顶部区域声明并定义了三个变量,分别是sprite2d_positionplayer_speedsprite2d_speed。然后在整个代码文件中,我们就可以使用这些变量。如果不在顶部区域,而是在函数中声明变量,那么只能在函数体中使用这些变量。

sprite2d_position变量是Vector2类型,它是一个二维向量,表示二维空间中的一个坐标或方向。向量值为(100,0),表示在x轴上的100个单位距离,在y轴上的0个单位距离。这里的单位都是指屏幕上的像素单位。

player_speedsprite2d_speed都是float类型。它们的值是2PI,表示旋转的速度是\(2\pi\),单位是弧度。PI是Godot内置的数学常数,2PI等于360度。相应的在_process函数中,它们分别被用来控制Player节点和Sprite2D节点的旋转速度。

那为什么需要乘以delta?这是为了实现帧无关的旋转速度,即使在不同的帧率下,旋转速度也是相同的。不论是计算机性能如何,旋转速度保持一致,都是一秒钟旋转360度的速度。

3.6 变量修饰符实操步骤

直接使用$符号来引用子节点会存在问题,就像是用写死的值来定义速度变量一样,如果子节点的名字或路径发生了改变,那么这个代码就会失效,这时候就需要使用修饰变量来引用子节点的属性,下面是一个简单的例子:

@onready var sprite_2d: Sprite2D = $Sprite2D
这里定义了一个变量sprite_2d,这样在代码中就可以直接使用sprite_2d来引用子节点及其属性,而不需要使用$符号来引用子节点。如果子节点的名字或路径发生改变,只需要改这一个地方即可。

之所以使用@onready修饰符来标注这个变量,是因为节点是一种特殊类型,它只能在节点准备好以后才能被引用。使用@onready可以帮助你确保在脚本中引用的节点已经准备好,从而避免潜在的错误。

另一种修饰符是@export,它用于在编辑器中显示和编辑变量的属性,可以让你在编辑器中直接修改变量的值。例如我们将player_speed变量声明为@export,那么在编辑器中就可以直接修改player_speed的值:

@export var player_speed :float = 2*PI
alt text

你可以点击player场景的Player节点,右侧属性面板中已经可以看到player_speed的值,这样就可以直接在编辑器中修改了。如果你在Player场景中修改了player_speed的值,那么它会覆盖掉代码文件中的player_speed的值。而且,在level场景中,所有的Player实例都会使用这个值。

你也可以在level场景中,修改不同Player实例的值,比如修改左边那个Player的值到30,那么左边的会转的更快,而右边那个保持不变。

4.条件语句

在游戏中,角色不仅需要持续地旋转、移动,还需要根据不同的情况做出不同的选择:

  • “如果敌人靠近,就攻击!”
  • “如果血量为 0,就死亡!”
  • “如果按下跳跃键,就跳起来!”

这些“如果……就……”的行为背后,依靠的就是程序中的条件判断语句。

在GDScript中,条件语句的写法是使用if关键字,后面接一个逻辑表达式来判断是否满足条件,如果满足条件,就执行相应的代码块,如果不满足条件,就跳过代码块。注意条件表达式后面的冒号,后续的代码部分需要缩进。

我们想实现这样一个功能,就是当Player旋转三圈后,就会自动停止旋转,这时候就需要使用到条件语句了。在Player场景中修改player.gd脚本,_process函数修改为如下代码:

func _process(delta: float) -> void:
    rotation += player_speed * delta
    sprite_2d.rotation += sprite2d_speed * delta
    if rotation/(2*PI) > 3:
        print("rotate 3 times")
        set_process(false)
代码解释:

  • rotation 表示当前旋转角度(单位是弧度);
  • 一圈是 2 * PI 弧度;
  • 当旋转角度除以一圈的弧度大于 3,说明角色已经旋转超过 3 圈;
  • 于是我们使用 set_process(false) 停止_process() 的调用,让角色不再旋转。

这就是条件语句的典型用法:当达到某个状态,就触发特定行为。如果我们有多种条件,可以使用elifelse来判断。

5.函数定义与调用

随着我们写的游戏逻辑越来越多,你可能发现代码开始变得冗长、重复、不好维护。为了让代码更清晰、更易复用,我们可以将一段有特定用途的代码“打包”起来,做成一个模块。这种模块在编程中就叫作函数(Function)。

函数是一组执行特定任务的代码,可以被多次调用。函数可以接受参数,执行一系列操作,并返回结果。

在GDScript中,使用 func 关键字来定义函数,后面接自定义的函数名和参数。函数的基本结构如下:

func function_name(parameters):
    # 函数体
    # 执行一系列操作
    return result  # 可选的返回值

  • func 是定义函数的关键字;
  • 括号中可以写入参数,用于传递数据;
  • return 用来把结果“送回去”,调用函数的地方可以使用这个结果;如果不需要返回结果,可以省略 return

让我们修改一下player.gd中的代码,增加一个stop_rotate函数,用来停止旋转。

func _process(delta: float) -> void:
    rotation += player_speed * delta
    sprite_2d.rotation += sprite2d_speed * delta
    stop_rotate(3)

func stop_rotate(count: int) -> void:
    if rotation/(2*PI) > count:
        print("rotate %s times" %count)
        set_process(false)
代码解释:stop_rotate函数接受一个参数count,表示旋转的次数,然后判断旋转的次数是否大于count,如果是的话,就打印一条消息,然后调用set_process函数来停止旋转。函数中使用了count参数,如果说你想旋转更多的次数,那么只需要修改count的值就可以了。

注意这句代码rotate %s times%count是一个格式化字符串,%s是一个占位符,它会用count值替换,然后打印出来。这个技巧非常有用,可以让你在打印信息时动态地添加变量的值。

写函数的好处包括:

  • 代码复用:通过将常用的功能封装在函数中,你可以在多个地方调用同一个函数,而不需要重复编写相同的代码。这提高了代码的复用性,减少了冗余。

  • 代码模块化:函数将代码划分为独立的模块,每个模块负责一个特定的任务。这使得代码更容易理解和维护,特别是对于大型项目。

6.数组和循环

在游戏中,常常需要同时处理多个对象,比如:

  • 一组敌人;
  • 多个道具;
  • 角色的装备清单;
  • 可用技能列表……

这些“成群的东西”在代码中,通常使用数组(Array) 来管理,而要操作数组中的每一项,就需要用到循环(Loop)。

6.1 数组

数组是一种数据结构,用来保存一组有顺序的数据。你可以把它想象成一个列表盒子,每个格子里装着一个值。

数组中的元素可以是任何类型,包括基本数据类型(如整数、浮点数、字符串)、复杂数据类型(如数组、字典、对象)等。例如我们可以定义包含三种水果的数组变量:

var fruits = ["apple", "banana", "orange"]

常见的数组操作包括:

var items = [1, 2, 3]

items.append(4)        # 添加元素
items.insert(1, 99)     # 在索引1位置插入99
items.erase(2)          # 删除值为2的元素
items[0] = 10           # 修改第0个元素为10
print(items.size())     # 输出数组长度
print(items.has(99))    # 判断是否包含99

下面给我们的场景增加一个功能,也就是实现一个类似扔骰子的功能,随机生成一个1到6的数值,然后用这个随机数作为旋转的次数,来旋转角色。在GDScript中,使用数组可以很方便地实现这个功能,修改player.gd代码如下:

var rotate_numbers :Array = [1,2,3,4,5,6]
var count = rotate_numbers.pick_random()

func _process(delta: float) -> void:
    rotation += player_speed * delta
    sprite_2d.rotation += sprite2d_speed * delta
    stop_rotate(count)
代码解释:首先定义了一个名为rotate_numbers的数组,里面存储了1到6的数字,然后使用内置的pick_random函数来随机选择一个数字,作为旋转的次数。我们运行level场景,会发现不同角色的旋转次数是不同的。

6.2 For 循环

在GDScript中,for循环是一种常用的控制流结构,用于重复执行一段代码。for 循环常常和数组等可迭代对象一起使用,用于遍历数组中的每个元素。

让我们找到level场景,给level场景的根节点挂载一个新的代码脚本,名字叫作level.gd。我们想实现的功能是,找到level场景中所有的player实例,打印出每个实例的名字、旋转速度和旋转次数。因为只需要打印一次,所以在level.gd的_ready函数中编写如下代码。

func _ready() -> void:
    var child_nodes = get_children()
    for child in child_nodes:
        print("my name is: %s" %child.name)
        print("my position is: %s" %child.position)
        print("my speed is: %s" %child.player_speed)
        print("I have to rotate %s times" %child.count)
代码解释:

  • get_children() 函数用于获得当前节点的所有子节点,所以level场景中的所有子节点。
  • 然后将这些子节点赋值给变量child_nodes,它是一个数组。
  • 然后使用for循环遍历这个数组,打印出每个子节点的名字、位置、旋转速度和旋转次数。你可以从控制台看到打印的信息。

alt text

7.输入事件与交互

在前面的示例中,我们让角色持续旋转、做判断、写函数、用数组管理多个对象,但有个关键问题还没有解决:玩家怎么和游戏互动?也就是说角色怎么知道玩家“点了鼠标”或者“按了按键”?

在之前第二章中,我们提到过输入事件,输入事件就是玩家在游戏运行时对游戏做出的操作,例如:

  • 按下键盘某个按键;
  • 点击鼠标;
  • 触摸屏幕;
  • 使用手柄摇杆。

Godot 会把这些操作识别为“事件”,然后通过代码传递给你,让你决定角色如何响应。Godot 提供了一套核心机制来处理输入:

  • 首先需要定义一系列的Input Map(输入映射):也就是定义你的动作名称(如 "jump"、"click"),并绑定到具体按键;
  • 然后我们可以在代码中检测这些动作是否被触发了,Godot提供了两套方法:
    • 使用_input(event) 函数:这是一种事件驱动型的输入处理,每当有新的输入事件发生时自动调用,你可以在函数里写具体的逻辑。
    • 使用Input单例,这是一种主动查询的输入处理方式,你可以在代码中主动检测动作是否被触发。

两种输入处理方式的简单对比如下:

特性 _input(event) Input.is_action_pressed()
触发方式 被动接收事件(Godot 调用) 主动查询输入状态
调用时机 每次有输入都会触发一次 每帧可检查是否按住
适合用来做 一次性操作(点击、跳跃) 持续行为(移动、拖拽)
是否包含事件信息 是(包含鼠标位置、按下/释放等细节) 否(只知道当前是否被按)

目前的例子中,我们只能看一个旋转的角色,但是实际上,游戏是需要处理玩家的输入,来控制角色的。我们需要增加一个功能,当用户点击鼠标左键时,旋转角色,当用户再次点击时停止旋转角色。

首先我们需要定义一个鼠标点击的输入事件。打开project settings,找到Input Map标签,在Add New Action的输入框中输入一个"click"的事件,然后点击右侧的add按键。

alt text

此时你可以在Action列表中看到新增的click事件,点击右侧的加号,在listining for input输入框中点击鼠标左键,你就给click事件绑定了一个输入事件。点击鼠标左键时,会触发click事件。下面我们来修改player.gd代码。

var start :bool = false

func _ready() -> void:
    sprite_2d.position = sprite2d_position
    set_process(false)

func _process(delta: float) -> void:
    rotation += player_speed * delta
    sprite_2d.rotation += sprite2d_speed * delta
    #stop_rotate(count)

func _input(event: InputEvent) -> void:
    if event.is_action_pressed("click"):
        start = not start
        set_process(start)
代码解释:

  • 代码中新增一个变量start,用于控制角色是否开始旋转,初始值为false
  • _ready函数中,将set_process设置为false,以便初始时不进行旋转。在_process函数中将stop_rotate函数注释掉,因为不需要这个函数来控制旋转。
  • 编写_input函数,这个函数也是Godot的内置函数,当有新的用户输入事件时,这个函数会被触发。_input函数有event事件参数,它会将用户的输入事件传递给这个函数
  • 然后我们来判断这个事件是不是click事件,也就是之前定义好的鼠标点击事件。
  • 当检测到用户点击了click事件,就会设置start的值为not start,也就切换start的值,之前逻辑值为false,现在逻辑值为true,然后根据start的值来控制set_process的状态。

代码修改后,回到level场景,运行level场景,点击鼠标左键,所有的角色开始旋转,再点击一次鼠标左键,停止旋转。

8.信号系统

在一个复杂的游戏中,角色、道具、界面、关卡逻辑常常需要互相协作。比如:

  • 玩家碰到敌人,血条要减少;
  • 点击按钮后,角色要开始旋转;
  • 怪物死亡后,播放爆炸动画并增加分数;

你当然可以用代码直接调用另一个节点的函数,比如 player.take_damage(10),但这会让两个节点强耦合,一改就容易乱。Godot 提供了更优雅的方式,就是使用信号(Signal),让对象之间通过“广播消息”来完成交互。

在第二章中,我们提到游戏的本质是事件驱动的,游戏逻辑不会一行行顺序执行,而是“谁发生了什么,就触发什么”。Godot 的 信号机制,就是这种事件驱动思想在引擎中的具体体现。

这些信号就像是一种“程序内部的事件通告”,谁感兴趣谁就响应,谁不感兴趣谁就不管。这种机制大大提高了游戏系统的模块化、响应性与灵活性。输入事件、碰撞事件、UI事件、动画事件都是信号在起作用。

8.1 Godot 中的信号机制

在 Godot 中,信号(Signal)是一种事件通知机制,用于在不同对象之间传递信息,而不需要彼此直接调用对方的函数。信号系统的核心由几个部分组成:

首先,发送者是一个节点或对象,它在某个特定时刻需要通知外部发生了什么,比如按钮被点击、敌人死亡、物体发生碰撞等。这些事件就是信号。Godot 中有许多内建信号,例如按钮节点的 pressed信号表示被按下、Area2D 的 body_entered信号表示进入区域,你也可以使用 signal 关键字定义自定义信号,例如 signal died来定义角色死亡的信号。

内置信号会在条件满足时自动触发,而自定义信号需要代码中手动触发。需要使用 emit 这个函数。它的作用是“广播一条消息”出去,告诉所有监听这个信号的对象:“我这里有事情发生了。”

但是有谁来监听或关心这个信号呢,接收者必须提前和发送者“连上线”,这就需要用到 connect() 函数。在函数中接收者还需要提供一个函数用于响应,这种函数通常称为回调函数。这样一来,当发送者发出信号时,接收者就会自动调用回调函数来响应这个事件。

整个流程就像一个广播电台:发送者通过 emit 播放广播;连接关系通过 connect 建立频道;接收者只要“调到这个频道”,一旦广播响起,就会执行相应的动作。这种机制非常适合构建松耦合、灵活、可扩展的游戏逻辑。

8.2 信号实操步骤

下面我们将之前的场景进行修改,在level场景中增加一个按键,玩家按一次,将会使得所有角色开始旋转,再次按一次,将会使得所有角色停止旋转。

从信号的概念上,当玩家点击按钮时,就会发出一个信号,然后通知所有关注这个信号的角色开始旋转,或者停止旋转。

打开player.gd代码文件,将_input函数注释掉,新增一个函数名为on_pressed,这个函数的作用就是响应信号,执行开关逻辑,执行代码如下所示:

func on_pressed() -> void:
    start = not start
    set_process(start)
然后我们回到level场景,增加一个按钮。方法是点击场景面板窗口上方的加号,新增一个节点,我们选择button节点。在button节点的text属性上输入"start"。找到font size设置将它的字体设置大一些。使用移动工具将它拖到窗口左上角看的到的地方。

alt text

在Godot中,每种节点都自带了某些信号,例如button节点的就自带了pressed信号,当用户点击按钮时,就会触发这个信号。你可以点击inspector右侧的Node标签,点击signals标签来观察button节点所拥有的信号。signals标签右边还有一个Groups标签,我们一会就会用到它。

打开level.gd代码文件,我们需要让button节点的点击事件和player节点的on_pressed函数绑定关联,修改代码如下:

@onready var button: Button = $Button

func _ready() -> void:
    var child_nodes = get_children()
    for child in child_nodes:
        if child.is_in_group("move"):
            button.pressed.connect(child.on_pressed)
            print("my name is: %s" %child.name)
            print("my position is: %s" %child.position)
            print("my speed is: %s" %child.player_speed)
            print("I have to rotate %s times" %child.count)
代码解释:

  • 首先用变量定义了button节点的引用;
  • 然后获取level场景中所有的子节点;
  • 遍历这些子节点,使用connect方法将button的pressed信号和player节点的on_pressed函数绑定关联。
  • 使用if 条件判断节点是否在move分组中,这里is_in_group函数用于判断节点是否在指定的分组中。

之所以需要条件判断的原因在于,level根节点下的子节点,不是每个节点都需要响应这个事件,只有两个player节点需要响应这个事件.但是button也在level的子节点中,因此要将button排除在外。有几种方法来做这个事件,例如判断节点的类型,或者使用分组标签。这里我们来介绍第二种方法。

回到player场景中,选择player根节点,在Groups标签中添加一个名为"move"的分组。分组类似于一种识别用的标签,用于区分不同用途的节点。打好分组标签的player场景应该如下所示。

alt text

回到level场景运行它。点击按钮,button节点会响应事件,所有的player节点开始旋转,再次点击按钮,所有的player节点停止旋转。 alt text

8.3 使用Button节点和信号

在之前章节中我们提到过,用于创建用户界面(UI)的节点,这类节点统称为 Control 节点。它拥有:

  • 自动布局功能:可以设置对齐、锚点、边距等,适配不同屏幕尺寸;
  • 输入处理机制:自动接收鼠标点击、键盘焦点等输入;
  • 主题与样式支持:支持字体、颜色、边框等丰富的 UI 样式定制。 Button 是 Control 的子类,表示一个可点击的按钮,是 Godot 中最常用的 UI 控件之一。它内置了一个pressed信号,表示按钮被点击。你可以通过信号系统将按钮点击行为与脚本中的函数进行绑定。用它能触发各种游戏逻辑,比如开始游戏、暂停、打开设置界面等。

在我们的例子中,player场景先准备好响应函数on_pressed,然后在level场景中增加了一个Button节点。在level场景的准备阶段,将button节点的pressed信号和player节点的on_pressed函数绑定关联。当点击start按钮时,button节点会自动发出pressed信号,player节点会自动响应这个信号。

9. Godot中的OOP

游戏是一个很复杂的软件工程,当功能越来越复杂时,我们需要一种编程设计思路,也就是我们在前面章节介绍过的面向对象编程。到目前为止,我们已经接触到了很多面向对象有关的概念,下面我们再次总结回顾,以便我们深入理解使用。

面向对象编程的的优点如下:

  • 分别用类来封装各自的数据和函数,代码相对独立,更容易修改和管理。
  • 让编程思路更清晰,编写更高效,更容易理解,不容易出错。
  • 可以直接使用现成的类,代码能更好的重用。

9.1 Godot中的类和对象

在我们的例子中,Node2D节点就是一个类,它定义了一组共有的属性和行为,属性包括positionrotation等变量,行为包括_process_ready等函数。

类和对象的关系,就相当于物种和个体的关系。物种定义了共同的特征和行为,个体是这个物种的实例,它具有自己的特征和行为。例如每只猫都有体型、毛发等共同的属性,但对于每只猫来说,具体的体型和毛发是不一样的。在我们例子中,Node2D是Godot内置的类,我们将它实例化放置在场景中,成为一个对象。不同的Node2D节点可以有不同的属性值,例如在Player场景中的根节点和level场景中的根节点,它们都是Node2D节点类型,但是两个名字不一样的实例。

9.2 Godot中的类和继承

物种之间有继承关系,猫科动物是哺乳动物的子类,也就是说猫科动物继承了哺乳动物的共有特征,但它会扩展出自己物种特有的特征。

如果你点击player节点,在右侧的inspector面板窗口,可以看到它的所有属性是分组的,最上层是player.gd,也就是通过代码扩展出的属性。下层是Node2D,也就是继承自Node2D的属性,而Node2D也是继承自其它更抽象的类,例如CanvasItem和Node。

在我们的例子中,Player节点是继承了Node2D节点的,所以Player节点可以使用Node2D节点的所有属性和方法。例如positionrotation。Player节点通过代码也扩展自己的特征,例如player_speed

9.3 Godot中的类和场景

在Godot中,每一种内置节点都可以看作是一个类。将节点放置在场景上,就成为一个实例化的对象。我们在节点上附加的代码,其实是对类的功能扩展。我们可以将player.gd代码中增加一行,定义扩展后的类名为Player。

extends Node2D
class_name Player
然后,在level场景中新增一个子节点时,可以搜索到新定义的Player类型。

alt text

而场景是节点的组合。场景并不完全等同于一个类。不过场景的行为与类有很多相似之处,场景是可保存、可实例化、可继承的节点组合,因此可以把场景看作是综合了多个节点和资源的蓝图。

在我们的例子中,Player场景就是这样的一个蓝图,level场景是由player和button节点组成的。保存后的场景就是一个 Player.tscn 文件,其实是一个由节点组成的结构图,加载后会成为 PackedScene 类型的资源。

当你把Player场景放入level场景中时,相当于实例化了Player蓝图。Player场景的根节点就成为你在父场景level中看到的“代表节点”。你可以在level场景中选择player节点,然后点击右键,选择editable children,这样就会将player节点的子节点进行展开方便你查看编辑。

为了更深入理解player场景,我们用一个文本编辑器vscode,来打开player.tscn文件,看看打包后的场景文件中包括了哪些信息。

[gd_scene load_steps=3 format=3 uid="uid://bhh3beryjj2sl"]

[ext_resource type="Script" path="res://player.gd" id="1_dfu51"]
[ext_resource type="Texture2D" uid="uid://d28mfilphq0il" path="res://icon.svg" id="2_evya4"]

[node name="Player" type="Node2D" groups=["move"]]
script = ExtResource("1_dfu51")

[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_evya4")

第一行中load_steps等于3,意思是本场景一共包括三种资源,分别是player.gd代码文件,icon.svg的图片文件,以及自身的tscn文件。uid是场景的标识符。之后ext_resource中的type是资源的类型,path是资源路径,id是资源的标识符。场景是树形结构,所以node name是节点的名字,type是节点的类型,parent是节点的父节点,groups是节点的分组,script是节点的脚本。可以看到Player是根节点,它没有父节点,Sprite2D是子节点,所以它的parent是一个点号,这里表示是根节点的路径。从这个文件内容可以看出,场景就是若干节点和资源的组合。

本小节初步介绍了Godot中的面向对象的编程思想,初学者刚开始不理解没有关系,通过代码实践会慢慢理解这一系列概念,而且本书在最后一章会详细讲解这方面的知识。

10. Godot运行机制详解

当你点击 Godot 编辑器顶部的“运行”按钮时,引擎并不是简单地打开了一个窗口,而是在幕后启动了一场精密协作的“演出”。理解这场演出的幕后流程,能帮助你理清代码执行的先后顺序,从而避免很多常见的逻辑错误。

10.1 搭建舞台:创建场景树与加载资源

游戏启动的第一步是建立运行环境。Godot 会首先创建一个名为 SceneTree (场景树) 的核心管理对象。你可以把它想象成剧院的“总导演”,它负责管理所有节点的生命周期、处理场景切换,并运行游戏的主循环。与此同时,导演会创建一个主视口 (Viewport),这是游戏图像显示和玩家输入交互的根容器。

紧接着,导演会读取你在项目中指定的“主场景”文件(.tscn)。此时,场景还只是一份存储在硬盘上的“静态蓝图”。Godot 会解析这份蓝图,找出其中定义的所有节点,并开始加载它们所引用的资源,比如角色穿的“衣服”(纹理图片)、发出的“声音”(音频文件)以及赋予它们灵魂的“逻辑”(脚本文件)。

10.2 赋予生命:从实例化到构造函数

在资源准备就绪后,Godot 开始将蓝图转化为真实的“演员”,这个过程称为实例化。引擎会根据节点类型(如 Node2D 或 Sprite2D)在内存中创建实际的对象,并按照你在编辑器里设置的值来初始化它们的坐标、旋转和贴图属性。如果某个节点挂载了脚本,Godot 会聪明地将其识别为你定义的“扩展类”。

在这个阶段,每个对象被创建时都会立即触发 _init() 构造函数。这是脚本中第一个被执行的函数,但此时“演员”还在后台化妆,尚未走上舞台。因此,在 _init() 中你无法访问该节点的子节点(因为子节点可能还没创建出来),它通常只被用来初始化一些基础的数据变量。

10.3 登台亮相:信号与ready

当所有节点都实例化完毕后,它们会被正式添加进场景树中。对于开发者来说,这是最关键的时刻。在节点踏上舞台的一瞬间,标记了@onready 的变量会首先完成赋值。这是为了确保像 $Sprite2D 这样的子节点引用能够准确找到目标,避免出现“找不到对象”的空指针错误。

随后,Godot 会依次调用每个节点的_ready()函数。有趣的是,调用的顺序是“从底向上”的:即先确保所有子节点都准备好了,父节点才会触发 _ready()。这就像一场婚礼,伴郎伴娘先站好位,主角才最终登场。因此,_ready() 是编写初始化逻辑的最佳场所,因为此时你可以放心地操作任何子节点或连接各种信号。

10.4 幕拉开:进入主循环

一旦所有节点的 _ready() 都执行完毕,游戏便正式进入了“持续运行”状态,即主循环。从这一刻起,SceneTree 开始马不停蹄地执行两件事:一是渲染循环,负责将画面绘制到屏幕上;二是逻辑更新。

你会发现脚本中的 _process(delta)_physics_process(delta) 开始被疯狂调用。前者跟随显示器的刷新率运行,处理如角色旋转、输入检测等视觉逻辑;后者则以固定的频率(默认每秒 60 次)运行,专门处理涉及重力和碰撞的物理计算。正是这种高频的循环往复,才让原本静止的节点在玩家眼中变成了流畅运行的游戏世界。

为了更直观地展示这个过程,我为你准备了一张运行机制流程图:

flowchart TD
    %% 阶段一:准备
    subgraph Prepare [准备阶段:加载蓝图]
        A[点击运行按钮] --> B[创建 SceneTree 总导演]
        B --> C[解析主场景 .tscn]
        C --> C1[加载静态资源: 图片/音频/脚本]
    end

    %% 阶段二:诞生
    subgraph Instance [初始化阶段:赋予生命]
        C1 --> D[实例化节点对象]
        D --> D2[执行 _init 构造函数]
        D2 --> D3[挂载脚本: 形成自定义类实例]
        D3 --> E[节点进入场景树]
        E --> H[执行 @onready 变量赋值]
        H --> F[子节点 _ready]
        F --> G[父节点 _ready]
    end

    %% 阶段三:运行
    subgraph Loop [运行阶段:游戏循环]
        G --> I[启动主循环]
        I --> I1[渲染循环: 绘制画面]
        I --> I2[逻辑循环: 每帧调用 _process]
        I2 -.-> I
    end

    %% 样式美化
    style Prepare fill:#f9f,stroke:#333,stroke-width:2px
    style Instance fill:#bbf,stroke:#333,stroke-width:2px
    style Loop fill:#bfb,stroke:#333,stroke-width:2px

通过对 Godot 运行机制的梳理,我们可以得出一套清晰的逻辑模型。请记住以下五点核心结论,它们将指引你后续的编程实践:

  • 资源(Resource)是静态的: 它们是存储在硬盘上的数据(如 .png 或 .gd),必须先加载进内存才能使用。

  • 节点(Node)是动态的: 它们是类的实例,构成了游戏的具体结构和“演员表”。

  • 脚本(Script)是灵魂: 它定义了行为,通过“继承”和“扩展”让普通的节点变成了功能复杂的自定义对象。

  • 场景(Scene)是蓝图: 它是节点的组合方式,通过“实例化”可以产生无数个各具特色的克隆体。

  • 场景树(SceneTree)是中枢: 它是整个游戏世界的运行心脏,掌控着从启动到关闭的每一个节拍。

本章小结:

本章我们深入学习了 Godot 的脚本语言 GDScript,掌握了让节点“活起来”的基本方法,也正式迈出了编写游戏逻辑的第一步。你学会了:

  • GDScript 的语言特点:专为游戏开发设计,语法简洁,紧密集成 Godot 引擎;
  • 脚本挂载机制:如何将代码附加到节点上,为对象添加行为;
  • 变量与类型提示:管理数据、控制行为、提升可读性和稳定性;
  • 数组与循环:批量处理对象,实现一对多控制;
  • 输入与交互:通过 Input Map 与玩家建立连接,监听和响应用户操作;
  • 信号系统:构建松耦合的事件系统,让对象之间能够自动对话、灵活协作。

这一章就像搭建了一套“角色大脑”,为每个游戏元素赋予了响应事件和执行逻辑的能力。你已经不仅仅是拖拽图形,而是在真正编写一个会思考的世界。