Godot GDScript 核心编程方法

in STEEM CN/中文12 days ago

好的,这是根据最终版HTML内容生成的完整Markdown版本。


Godot GDScript 核心编程方法

一份专为进阶学习者准备的全面教学材料

目录


第一章:脚本与节点——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)附加到多个不同的敌人节点上,它们会共享相同的行为逻辑,但各自拥有独立的状态(如生命值、位置等)。这种设计模式极大地提高了代码的复用性。

第一章:思考题

  1. 如果我想让一个Sprite2D节点在屏幕上从左向右移动,应该在哪里编写控制移动的代码?为什么?
  2. 我能否将一个控制玩家行为的脚本(例如继承自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,从而避免了“点击按钮的同时角色还在开枪”这类问题。

第二章:练习题

  1. 创建一个场景,包含一个Button和一个Sprite2D。给Sprite2D附加脚本,在_unhandled_input中实现点击鼠标左键就移动到鼠标位置。运行游戏,测试点击按钮和点击空白区域的区别。
  2. 在上面的脚本中,再添加一个_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),因为它没有成功获取到你想要的节点引用。

导致这个错误的主要原因:

  1. 路径错误get_node()$中的路径字符串不正确。
  2. 时机过早:你在_ready()函数被调用之前就尝试获取节点。例如,在脚本顶部的成员变量区直接调用get_node()。此时目标节点可能还没有被添加到场景树中。这就是为什么我们需要将获取节点的操作放在_ready()函数中,或者使用@onready注解。

第三章:练习题

  1. 建立一个包含ButtonLabel的场景。给Button附加一个脚本。当按钮被按下时(使用按钮的pressed信号),通过脚本获取Label节点的引用,并将其文本更改为 "Button was pressed!"。
  2. 创建一个玩家(Player)和一个敌人(Enemy)。在玩家的脚本中,使用_process函数持续打印出敌人节点的全局位置(global_position)。
  3. 将上一个练习中的代码重构,使用@export var enemy_node: Node2D的方式来获取敌人节点,并在编辑器中将敌人节点拖拽到对应的属性栏中。

第四章:Transform——掌控节点的空间变换

本章将深入讲解Transform,这是一个包含了节点位置、旋转和缩放信息的关键数据结构。理解Transform的构成(特别是它的origin和基向量basis)将使你能够以更强大和灵活的方式操控2D和3D空间中的节点,而不仅仅是简单地修改positionrotation属性。

每个Node2DNode3D都有一个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类似,但更复杂。它由一个originVector3类型的位置)和一个Basis(基)组成。

  • origin: 节点的3D位置。
  • basis: 一个包含三个Vector3x, 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 全局坐标

节点的transformposition属性都是相对于其父节点的,这被称为“本地(local)坐标”。如果你需要获取或设置一个节点在整个游戏世界中的位置,你应该使用它的“全局(global)”属性:global_transformglobal_position

当一个父节点移动时,它所有子节点的全局位置都会随之改变,但它们的本地位置(相对于父节点)保持不变。理解这种层级关系对于构建复杂的场景(如玩家手上拿着的武器)至关重要。

第四章:练习题

  1. 创建一个Node2D作为炮塔的基座,再创建一个Sprite2D作为它的子节点,代表炮管。编写脚本附加到基座上,让炮管(子节点)始终朝向鼠标的位置。(提示:在父节点脚本中获取子节点引用,然后使用look_at(get_global_mouse_position()))。
  2. 编写一个脚本,让一个CharacterBody2D在按下“左/右”方向键时,不是移动,而是进行原地旋转。按下“上”方向键时,让它沿着自己当前朝向(本地坐标的Y轴负方向)前进。

第五章:综合练习——打造一个简单的射击游戏

本章将综合运用前面学到的所有知识——生命周期函数、节点获取和Transform变换——来制作一个可以移动和射击的2D俯视角玩家。这是一个检验学习成果的绝佳实践。

第一步:创建玩家场景

  1. 创建一个新的场景,根节点为 CharacterBody2D,命名为 "Player"。
  2. 为玩家添加一个 Sprite2D (用于显示玩家图像) 和一个 CollisionShape2D (用于物理碰撞)。
  3. Sprite2D下添加一个Marker2D节点,命名为"Muzzle",并把它拖到炮管应该在的位置。
  4. 为玩家根节点附加一个新的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()

第三步:创建子弹场景

  1. 创建一个新的场景,根节点为 Area2D,命名为 "Bullet"。
  2. 给子弹添加 Sprite2DCollisionShape2D
  3. 为子弹附加一个新的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设置为玩家“枪口”Marker2Dglobal_transform。这保证了子弹在正确的位置以正确的角度被发射出去,完美地运用了Transform知识。

第五章:扩展与挑战

  1. 添加一个计时器(Timer节点),实现射击冷却,防止玩家无限快速地发射子弹。
  2. 创建敌人场景,让子弹能够通过Area2Dbody_entered信号检测到并摧毁敌人。
  3. 为主场景添加一个UI,使用get_node获取UI中的Label,并在摧毁敌人时更新得分。
Sort:  

Upvoted! Thank you for supporting witness @jswit.

Coin Marketplace

STEEM 0.08
TRX 0.29
JST 0.036
BTC 101879.02
ETH 3418.19
USDT 1.00
SBD 0.56