Godot GDScript 核心编程方法
好的,这是根据最终版HTML内容生成的完整Markdown版本。
Godot GDScript 核心编程方法
一份专为进阶学习者准备的全面教学材料
目录
- 第一章:脚本与节点——Godot的灵魂
- 第二章:生命周期函数——在正确的时间做正确的事
- 第三章:获取节点与属性——织就场景交互之网
- 第四章:Transform——掌控节点的空间变换
- 第五章:综合练习——打造一个简单的射击游戏
第一章:脚本与节点——Godot的灵魂
本章将介绍GDScript与Godot节点系统之间不可分割的关系。你需要掌握的核心知识点是:脚本是附加在节点上的组件,用于定义节点的行为、响应输入以及与其他节点进行交互。理解这一点是深入学习Godot编程的基础。
在Godot中,万物皆节点。一个角色、一把武器、一个UI元素、甚至一个计时器,都是通过不同类型的节点(Node)来表示的。这些节点像积木一样被组织在一棵“场景树”上。然而,节点本身只包含数据和基础功能(例如,Sprite2D节点知道如何显示一张图片,但不知道如何移动)。要让这些节点“活”起来,我们就需要为它们附加脚本。
GDScript作为Godot的官方语言,被设计为与这种基于节点的架构紧密集成。当你将一个GDScript文件附加到一个节点上时,这个脚本就成为了该节点的“大脑”。脚本通过extends关键字继承节点原有的类,这意味着你在脚本中可以直接访问和修改该节点的所有属性,比如位置(position)、旋转(rotation)等,并且可以调用其内置的函数。
例如,一个附加在CharacterBody2D节点上的脚本,可以直接在代码中使用move_and_slide()函数来实现物理移动,因为move_and_slide()是CharacterBody2D类的一部分。这种“胶水”般的特性使得GDScript能够极其方便地控制场景中的一切。
核心概念辨析:脚本 vs 节点
一个常见的误区是认为脚本本身就是一个游戏对象。请记住:节点才是场景中的实体,脚本只是定义其行为的蓝图。你可以将同一个脚本(例如
Enemy.gd)附加到多个不同的敌人节点上,它们会共享相同的行为逻辑,但各自拥有独立的状态(如生命值、位置等)。这种设计模式极大地提高了代码的复用性。
第一章:思考题
- 如果我想让一个
Sprite2D节点在屏幕上从左向右移动,应该在哪里编写控制移动的代码?为什么? - 我能否将一个控制玩家行为的脚本(例如继承自
CharacterBody2D)附加到一个Button节点上?为什么这样做可能不是一个好主意?
第二章:生命周期函数——在正确的时间做正确的事
本章的核心是掌握Godot提供的特殊“钩子”或“生命周期”函数。这些函数由引擎在特定时刻自动调用(例如节点进入场景时、每一帧更新时),它们都以下划线(
_)开头。理解_ready(),_process(delta),_physics_process(delta)以及两种输入函数_input(event)和_unhandled_input(event)的区别与用途至关重要。
Godot为我们提供了一套预定义的虚函数,我们可以在脚本中覆盖它们,以便在游戏运行的特定阶段执行代码。这让我们的代码组织更有条理。
_ready(): 当一个节点及其所有子节点都进入活动场景树时,这个函数会被调用一次。它是执行初始化设置的理想场所,比如获取其他节点的引用、设置变量的初始值、或者连接信号。在这个函数被调用时,你可以安全地假设场景中的其他节点也已经准备就绪。_process(delta): 这个函数在每一帧渲染时被调用。参数delta表示自上一帧以来经过的时间(以秒为单位)。它非常适合处理需要平滑更新的非物理逻辑,例如视觉效果、动画控制等。_physics_process(delta): 这个函数以固定的时间间隔被调用(默认为每秒60次)。这里的delta是一个固定的值。所有与物理相关的计算,如移动CharacterBody2D/3D、施加力、检测碰撞等,都应该放在这里。_input(event): 当任何输入事件(如键盘按下、鼠标点击)发生时,这个函数会被调用。它会优先捕获输入,甚至在GUI元素(如按钮)之前。_unhandled_input(event): 只有当一个输入事件没有被_input函数或者场景中的任何GUI节点(如Button,LineEdit等)“消耗”掉时,这个函数才会被调用。
疑难问题辨析
_process还是_physics_process?一个简单的判断标准是:“我的代码是否会影响物体的物理状态(位置、速度、碰撞)?”
- 如果是,比如控制一个
CharacterBody2D的移动,那么请使用_physics_process,以保证物理计算的稳定性。- 如果不是,比如只是让一个UI元素渐变消失,那么使用
_process就足够了,这样可以获得最平滑的视觉效果。
_input还是_unhandled_input?这是决定输入逻辑是否健壮的关键。输入事件在Godot中的传递顺序大致是:
_input()-> GUI 元素 ->_unhandled_input()。
- 使用
_input的场景:当你需要无条件地捕获某个输入时,比如一个全局的暂停键,或者在开发调试工具时。但要小心,如果你在_input里处理了鼠标点击,那么这个点击事件可能就无法触发场景中的按钮了。- 使用
_unhandled_input的场景:这是处理绝大多数游戏性输入(如角色移动、跳跃、射击)的最佳选择。因为当玩家点击游戏UI(比如一个“商店”按钮)时,这个输入事件被GUI消耗掉了,就不会传递到_unhandled_input,从而避免了“点击按钮的同时角色还在开枪”这类问题。
第二章:练习题
- 创建一个场景,包含一个
Button和一个Sprite2D。给Sprite2D附加脚本,在_unhandled_input中实现点击鼠标左键就移动到鼠标位置。运行游戏,测试点击按钮和点击空白区域的区别。 - 在上面的脚本中,再添加一个
_input函数,当按下键盘上的“P”键时,在控制台打印 "Paused!"。思考为什么这里用_input是合适的。
第三章:获取节点与属性——织就场景交互之网
本章将讲解在脚本中如何获取对场景树中其他节点的引用,并读取或修改它们的属性。这是实现节点间通信和复杂游戏逻辑的关键。核心知识点包括使用
get_node()方法、$快捷语法以及更现代、更安全的@export和@onready注解。
在游戏中,节点很少独立工作。玩家需要与敌人交互,UI需要更新分数,按钮需要触发事件。所有这些都需要一个节点能够“找到”并“对话”另一个节点。
1. get_node() 方法
这是获取节点引用的最基本方法。它接受一个节点路径(NodePath)作为参数。
- 相对路径: 从当前节点开始查找。
# 获取名为 "MySprite" 的子节点 var sprite = get_node("MySprite") # 获取父节点 var parent_node = get_node("..") # 获取名为 "Sibling" 的兄弟节点 var sibling_node = get_node("../Sibling") - 绝对路径: 从场景树的根节点
/root/开始查找。# 获取场景 "Main" 下名为 "Player" 的节点 var player = get_node("/root/Main/Player")
2. $ 快捷语法
这是一种更简洁的写法,等同于get_node()。它更易读,并且在节点名称或路径更改时,编辑器会给出警告或自动更新。
版本差异:在 Godot 3.x 中,$ 符号主要用于获取直接子节点。然而,从 Godot 4.x 开始,其功能得到了显著增强,现在可以解析更复杂的相对路径,例如使用 ../ 来访问父节点或兄弟节点。
# 获取直接子节点 (Godot 3 & 4)
var sprite = $MySprite
# 获取兄弟节点 (Godot 4+)
var sibling_node = $../Sibling
3. 现代方法:@export 和 @onready
硬编码路径(如get_node("Player/Gun"))非常脆弱,一旦你在编辑器中移动或重命名节点,代码就会出错。更稳健的方法是使用导出变量。
# 在脚本顶部声明一个导出的节点路径变量
@export var label_path: NodePath
# 使用 @onready 确保在节点准备好时才获取引用
@onready var my_label: Label = get_node(label_path)
func _ready():
# 现在可以直接使用 my_label
if my_label:
my_label.text = "Reference Acquired!"
这样做的好处是,你可以在Godot编辑器的“检查器”面板中,通过拖拽的方式将目标节点赋值给这个变量。这样即使你改变了节点的层级结构,只要重新在检查器里指定一次,代码就无需任何改动。
交互式场景树
假设你的场景树如下。以下是从 Player 脚本访问其他节点所需的路径 (以 Godot 4.x 语法为例)。
- Main (Node2D) - Player (CharacterBody2D) - Enemy (CharacterBody2D) - UI (CanvasLayer) - ScoreLabel (Label)
- 从 Player 脚本获取 Enemy:
get_node("../Enemy")或$../Enemy- 从 Player 脚本获取 ScoreLabel:
get_node("../UI/ScoreLabel")或$../UI/ScoreLabel- 从 Player 脚本获取 UI:
get_node("../UI")或$../UI- 从 Player 脚本获取 Main:
get_parent()或get_node("..")
疑难问题:'Attempt to call function '...' in base 'null instance'.'
这是Godot开发中最常见的错误。它的意思是:你试图调用的变量是一个空值(null),因为它没有成功获取到你想要的节点引用。
导致这个错误的主要原因:
- 路径错误:
get_node()或$中的路径字符串不正确。- 时机过早:你在
_ready()函数被调用之前就尝试获取节点。例如,在脚本顶部的成员变量区直接调用get_node()。此时目标节点可能还没有被添加到场景树中。这就是为什么我们需要将获取节点的操作放在_ready()函数中,或者使用@onready注解。
第三章:练习题
- 建立一个包含
Button和Label的场景。给Button附加一个脚本。当按钮被按下时(使用按钮的pressed信号),通过脚本获取Label节点的引用,并将其文本更改为 "Button was pressed!"。 - 创建一个玩家(Player)和一个敌人(Enemy)。在玩家的脚本中,使用
_process函数持续打印出敌人节点的全局位置(global_position)。 - 将上一个练习中的代码重构,使用
@export var enemy_node: Node2D的方式来获取敌人节点,并在编辑器中将敌人节点拖拽到对应的属性栏中。
第四章:Transform——掌控节点的空间变换
本章将深入讲解
Transform,这是一个包含了节点位置、旋转和缩放信息的关键数据结构。理解Transform的构成(特别是它的origin和基向量basis)将使你能够以更强大和灵活的方式操控2D和3D空间中的节点,而不仅仅是简单地修改position或rotation属性。
每个Node2D和Node3D都有一个transform属性。它是一个数学上的矩阵,但我们不必深入矩阵数学也能很好地使用它。我们可以把它看作一个描述了节点“坐标系”的对象。
Transform2D (用于2D)
一个Transform2D由三个Vector2构成:
origin: 这就是节点的position,代表了节点坐标系的原点在其父节点坐标系中的位置。x: 一个向量,代表了节点本地X轴的方向和长度(缩放)。默认情况下是Vector2(1, 0)。y: 一个向量,代表了节点本地Y轴的方向和长度(缩放)。默认情况下是Vector2(0, 1)。
实用案例:让角色永远朝向自己的“前方”移动。
# 假设角色的“前方”是其本地X轴正方向
var speed = 200.0
func _physics_process(delta):
if Input.is_action_pressed("move_forward"):
# transform.x 代表了角色当前的朝向向量
position += transform.x * speed * delta
Transform3D (用于3D)
Transform3D与2D类似,但更复杂。它由一个origin(Vector3类型的位置)和一个Basis(基)组成。
origin: 节点的3D位置。basis: 一个包含三个Vector3(x,y,z)的结构,分别代表节点本地的X, Y, Z轴。
实用案例:让3D角色朝向自己的“前方”移动(Godot中,3D节点的-Z轴通常被视作“前方”)。
var speed = 5.0
func _physics_process(delta):
if Input.is_action_pressed("move_forward"):
# -transform.basis.z 是角色当前的前方向量
velocity = -transform.basis.z * speed
move_and_slide()
2D Transform 交互演示
[描述一个交互式组件:一个Godot图标位于一个2D坐标系中,其中心点代表
origin。一条红色向量从origin出发,代表本地X轴;一条绿色向量代表本地Y轴。用户可以通过拖动图标来改变origin的值,通过滑块来改变旋转和X/Y轴的缩放。所有的操作都会实时更新下方显示的Transform数据。]一个可能的输出状态如下:
origin: (210, 145) x: (0.87, 0.50) y: (-0.50, 0.87)
疑难问题:本地坐标 vs 全局坐标
节点的
transform和position属性都是相对于其父节点的,这被称为“本地(local)坐标”。如果你需要获取或设置一个节点在整个游戏世界中的位置,你应该使用它的“全局(global)”属性:global_transform和global_position。当一个父节点移动时,它所有子节点的全局位置都会随之改变,但它们的本地位置(相对于父节点)保持不变。理解这种层级关系对于构建复杂的场景(如玩家手上拿着的武器)至关重要。
第四章:练习题
- 创建一个
Node2D作为炮塔的基座,再创建一个Sprite2D作为它的子节点,代表炮管。编写脚本附加到基座上,让炮管(子节点)始终朝向鼠标的位置。(提示:在父节点脚本中获取子节点引用,然后使用look_at(get_global_mouse_position()))。 - 编写一个脚本,让一个
CharacterBody2D在按下“左/右”方向键时,不是移动,而是进行原地旋转。按下“上”方向键时,让它沿着自己当前朝向(本地坐标的Y轴负方向)前进。
第五章:综合练习——打造一个简单的射击游戏
本章将综合运用前面学到的所有知识——生命周期函数、节点获取和Transform变换——来制作一个可以移动和射击的2D俯视角玩家。这是一个检验学习成果的绝佳实践。
第一步:创建玩家场景
- 创建一个新的场景,根节点为
CharacterBody2D,命名为 "Player"。 - 为玩家添加一个
Sprite2D(用于显示玩家图像) 和一个CollisionShape2D(用于物理碰撞)。 - 在
Sprite2D下添加一个Marker2D节点,命名为"Muzzle",并把它拖到炮管应该在的位置。 - 为玩家根节点附加一个新的GDScript,命名为
player.gd。
第二步:编写玩家移动和朝向代码 (player.gd)
extends CharacterBody2D
const SPEED = 300.0
func _physics_process(delta):
# 朝向鼠标
look_at(get_global_mouse_position())
# 获取输入
var direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity = direction.normalized() * SPEED
# 移动
move_and_slide()
第三步:创建子弹场景
- 创建一个新的场景,根节点为
Area2D,命名为 "Bullet"。 - 给子弹添加
Sprite2D和CollisionShape2D。 - 为子弹附加一个新的GDScript,命名为
bullet.gd。
第四步:编写子弹飞行代码 (bullet.gd)
extends Area2D
const SPEED = 800.0
func _physics_process(delta):
# Godot 2D 中, -transform.y 是默认的 "前方"
position += -transform.y * SPEED * delta
第五步:在玩家脚本中实现射击
回到 player.gd。我们需要加载子弹场景,并在玩家点击鼠标时实例化它。
extends CharacterBody2D
const SPEED = 300.0
# 预加载子弹场景
const Bullet = preload("res://bullet.tscn")
# 使用 @onready 获取枪口位置
@onready var muzzle = $Muzzle
func _unhandled_input(event):
# 使用 _unhandled_input 是最佳实践
if event.is_action_pressed("fire"):
# 创建一个子弹实例
var bullet_instance = Bullet.instantiate()
# 将子弹添加到主场景中 (get_owner()更安全)
get_owner().add_child(bullet_instance)
# 设置子弹的初始变换,使其与枪口完全一致
bullet_instance.global_transform = muzzle.global_transform
func _physics_process(delta):
look_at(get_global_mouse_position())
var direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity = direction.normalized() * SPEED
move_and_slide()
在这个最终版本中,我们:
- 使用
preload来高效地加载子弹场景。 - 在
_unhandled_input中处理射击事件,以避免与UI冲突。 - 使用
instantiate()来创建子弹的实例。 - 最关键的一步:将子弹的
global_transform设置为玩家“枪口”Marker2D的global_transform。这保证了子弹在正确的位置以正确的角度被发射出去,完美地运用了Transform知识。
第五章:扩展与挑战
- 添加一个计时器(Timer节点),实现射击冷却,防止玩家无限快速地发射子弹。
- 创建敌人场景,让子弹能够通过
Area2D的body_entered信号检测到并摧毁敌人。 - 为主场景添加一个UI,使用
get_node获取UI中的Label,并在摧毁敌人时更新得分。
Upvoted! Thank you for supporting witness @jswit.