《GDScript 从浅入深》
《GDScript 从浅入深》
完全教程指南
—— 从零基础到游戏开发高手
前言
为什么选择 GDScript?
在开始这段学习旅程之前,我想先和你聊聊为什么 GDScript 值得你投入时间学习。
Godot 引擎近年来在游戏开发领域的崛起势不可挡。作为一个完全开源、免费且功能强大的游戏引擎,Godot 正在被越来越多的独立开发者、教育机构甚至小型工作室采用。而 GDScript,作为 Godot 的原生脚本语言,是通往这个强大引擎的最直接入口。
GDScript 的设计哲学可以用四个词概括:简单、优雅、集成、高效。
它的语法深受 Python 影响,如果你有过 Python 编程经验,上手 GDScript 几乎没有任何障碍。即使你完全不懂编程,GDScript 简洁的语法和 Godot 友好的学习曲线,也会让你比学习其他游戏开发语言更快产出成果。
这本书的目标是什么?
这是一本真正带你“从浅入深”理解 GDScript 的教程书。我们会从最基础的概念开始,比如“什么是变量”、“什么是函数”,逐步深入到信号机制、场景树架构、性能优化,乃至自定义资源与模块开发。
全书分为四个阶段:
- 入门篇(1-4章):掌握核心语法,能够编写简单的游戏逻辑
- 进阶篇(5-8章):理解 Godot 特有机制,学会场景组织和信号通信
- 高级篇(9-12章):性能优化、设计模式、工具脚本与自动化
- 实战篇(13-16章):通过完整游戏项目,巩固并综合运用所学知识
每一章都配有可运行的代码示例和课后练习。书中的示例基于 Godot 4.x 版本,所有代码都经过实际测试。
你需要准备什么?
- 一台可以运行 Godot 引擎的电脑(Windows / macOS / Linux 均可)
- Godot 4.x 版本(建议从官网下载最新稳定版)
- 一个趁手的代码编辑器(Godot 自带的脚本编辑器已经很不错,你也可以使用 VSCode 并安装 GDScript 插件)
- 最重要的:好奇心和耐心
现在,让我们开始吧。
第一部分:入门篇
第1章:初识 GDScript
1.1 你好,世界
打开 Godot,创建一个新项目。让我们从最经典的程序开始。
步骤:
- 新建场景,选择 2D 场景
- 在场景中添加一个 Label 节点
- 选中 Label 节点,点击右侧的“添加脚本”按钮(看起来像一本书的图标)
- 在脚本编辑器中输入以下代码:
extends Label
func _ready():
text = "你好,世界!"
print("你好,控制台!")
- 保存场景(比如命名为
Main.tscn) - 点击运行(F5)
你会看到屏幕上显示“你好,世界!”,同时 Godot 的输出控制台会打印出“你好,控制台!”。
恭喜!你已经写出了第一个 GDScript 程序。
代码解读:
extends Label— 说明这个脚本附着在 Label 节点上,并且继承了 Label 的所有功能func _ready()— 这是一个特殊的函数,当节点进入场景树时会自动执行text = "你好,世界!"— 将 Label 的 text 属性修改为我们指定的字符串print()— 一个内置函数,用于在控制台输出信息
1.2 什么是脚本?
在 Godot 中,脚本是赋予节点“生命”的代码。一个节点本身只有基本功能(比如一个 Label 只能显示文字,一个 Sprite2D 只能显示图片),而脚本让你可以定义这个节点应该做什么。
你可以把节点想象成一个空白的“角色”,脚本就是它的“剧本”。
1.3 脚本的构成
一个典型的 GDScript 脚本包含以下几个部分(按常见顺序):
# 1. 继承声明(必须)
extends Node2D
# 2. 类名声明(可选,用于全局访问)
class_name MyCustomNode
# 3. 信号声明(第7章会详细讲解)
signal health_depleted
# 4. 常量定义
const GRAVITY = 9.8
# 5. 枚举定义
enum Direction {UP, DOWN, LEFT, RIGHT}
# 6. 变量定义
var health = 100
@export var speed = 200
# 7. 内置回调函数
func _ready():
pass
func _process(delta):
pass
# 8. 自定义函数
func take_damage(amount):
health -= amount
if health <= 0:
health_depleted.emit()
1.4 Godot 脚本工作流程
理解 Godot 如何执行你的脚本非常重要:
- 场景加载:当你加载一个场景,Godot 会创建场景树中的所有节点
- 脚本附加:每个节点的脚本被加载并附加到对应节点上
- _init():最先执行,相当于构造函数
- _enter_tree():节点进入场景树时调用
- _ready():节点及其所有子节点都进入场景树后调用
- _process(delta):每一帧都会调用
- _physics_process(delta):每个物理帧调用(默认每秒60次)
- _exit_tree():节点离开场景树时调用
这个执行顺序很重要,我们在后续章节会反复用到。
1.5 代码注释
注释是写给人类(包括未来的自己)看的说明,Godot 会完全忽略它们。
# 这是单行注释
# 也可以使用
# 多个单行注释
# 来模拟多行注释
# 推荐在注释前加一个空格,更易读
# TODO: 这是一个待办事项注释,方便标记未完成的工作
# FIXME: 标记需要修复的问题
# NOTE: 重要的说明信息
1.6 常用的编辑器快捷键
| 快捷键 | 功能 |
|---|---|
| Ctrl + / | 注释/取消注释当前行或选中的代码块 |
| Ctrl + D | 复制当前行 |
| Tab | 缩进 |
| Shift + Tab | 反向缩进 |
| Ctrl + F | 查找 |
| Ctrl + R | 重命名符号 |
| Ctrl + Shift + F | 全局查找 |
| F1 | 打开上下文相关的文档 |
1.7 第一个练习
练习1.1:创建一个新的 Sprite2D 节点,附加脚本,在 _ready() 函数中修改它的 modulate 属性(颜色)为红色。
提示:颜色可以用 Color.RED 或 Color(1, 0, 0) 表示。
第2章:变量、数据类型与运算符
2.1 变量:存储信息的盒子
变量是程序中用来存储数据的“容器”。在 GDScript 中,你不需要声明变量的类型(但可以声明,我们稍后会讲到)。
# 创建变量使用 var 关键字
var score = 0
var player_name = "Hero"
var is_alive = true
# 变量可以在创建时不赋值(此时为 null)
var item_count
# 变量的值可以被改变
score = 100
player_name = "Legendary Hero"
变量命名规则:
- 必须以字母或下划线开头
- 只能包含字母、数字和下划线
- 区分大小写(
myVar和myvar是不同的) - 不能使用 GDScript 的保留字(如
var、func、if等)
命名约定:
- 变量名使用
snake_case(全小写,单词间用下划线):player_health、coin_count - 常量使用
SHOUTING_SNAKE_CASE:MAX_SPEED、DEFAULT_HP - 私有变量(仅在当前脚本内使用)建议以单下划线开头:
_internal_state
2.2 数据类型
GDScript 是动态类型语言,但具有可选的静态类型注解。让我们先了解内置的基本数据类型:
2.2.1 整数(int)
var age = 25
var hex_value = 0xFF # 十六进制,值为255
var binary = 0b1010 # 二进制,值为10
var negative = -42
2.2.2 浮点数(float)
var pi = 3.14159
var speed = 5.0
var scientific = 1.2e10 # 1.2 × 10^10
2.2.3 布尔值(bool)
var is_running = true
var has_key = false
# 布尔运算结果也是布尔值
var is_greater = 10 > 5 # true
2.2.4 字符串(String)
var greeting = "Hello"
var name = 'Alice' # 单引号也可以
var multi_line = """
这是多行字符串
可以跨越多个行
"""
# 字符串拼接
var full = greeting + " " + name # "Hello Alice"
# 字符串插值(推荐)
var message = "你好,%s!你的分数是 %d。" % [name, score]
# 更现代的插值方式(Godot 4)
var modern = "你好,{name}!分数:{score}".format({"name": name, "score": score})
2.2.5 类型推断与显式类型
GDScript 可以自动推断变量类型,你也可以显式声明:
# 自动推断
var auto_int = 10 # 推断为 int
var auto_float = 10.0 # 推断为 float
var auto_string = "hi" # 推断为 String
# 显式类型声明(推荐,有助于编辑器提供更好的代码补全)
var explicit_int: int = 10
var explicit_float: float = 3.14
var explicit_string: String = "hello"
var explicit_bool: bool = true
# 类型声明后,赋值为其他类型会导致错误
var health: int = 100
# health = "full" # 这行会报错!
2.3 运算符
2.3.1 算术运算符
| 运算符 | 名称 | 示例 | 结果 |
|---|---|---|---|
| + | 加法 | 5 + 3 | 8 |
| - | 减法 | 5 - 3 | 2 |
| * | 乘法 | 5 * 3 | 15 |
| / | 除法 | 5 / 2 | 2.5 |
| // | 整数除法 | 5 // 2 | 2 |
| % | 取模(余数) | 5 % 2 | 1 |
| ** | 幂运算 | 2 ** 3 | 8 |
var a = 10
var b = 3
print(a + b) # 13
print(a - b) # 7
print(a * b) # 30
print(a / b) # 3.333...
print(a // b) # 3
print(a % b) # 1
print(2 ** 4) # 16
2.3.2 比较运算符
| 运算符 | 名称 | 示例 |
|---|---|---|
| == | 等于 | 5 == 5 → true |
| != | 不等于 | 5 != 3 → true |
| > | 大于 | 5 > 3 → true |
| < | 小于 | 5 < 3 → false |
| >= | 大于等于 | 5 >= 5 → true |
| <= | 小于等于 | 3 <= 5 → true |
重要:注意 =(赋值)和 ==(相等比较)的区别!
var x = 10 # 赋值:把10放到x里
if x == 10: # 比较:x等于10吗?
print("x is 10")
2.3.3 逻辑运算符
| 运算符 | 名称 | 描述 |
|---|---|---|
| and | 逻辑与 | 两边都为真时结果为真 |
| or | 逻辑或 | 至少一边为真时结果为真 |
| not | 逻辑非 | 反转真假值 |
var age = 18
var has_license = true
# 可以开车吗?(年满16岁并且有驾照)
var can_drive = age >= 16 and has_license # true
# 可以免费入场吗?(5岁以下或者65岁以上)
var age = 70
var is_free = age < 5 or age > 65 # true
# 不能开车(没有驾照)
var cannot_drive = not has_license # false
2.3.4 复合赋值运算符
var score = 10
score += 5 # 等价于 score = score + 5 → 15
score -= 3 # 等价于 score = score - 3 → 12
score *= 2 # 等价于 score = score * 2 → 24
score /= 4 # 等价于 score = score / 4 → 6
score %= 3 # 等价于 score = score % 3 → 0
2.4 常量
使用 const 关键字定义不会改变的值:
const MAX_HP = 100
const GAME_TITLE = "冒险之旅"
const PI = 3.14159
# 尝试修改常量会报错
# MAX_HP = 200 # 错误!
常量在编译时就确定了,性能比变量更好。对于不会改变的值,应该使用常量。
2.5 枚举
枚举让代码更易读、更安全:
# 定义枚举
enum Direction {
UP, # 默认值为0
DOWN, # 1
LEFT, # 2
RIGHT # 3
}
# 可以手动指定值
enum PowerUp {
HEALTH = 1,
SPEED = 2,
DAMAGE = 4, # 可以是任意整数
}
# 使用枚举
var current_direction = Direction.RIGHT
match current_direction:
Direction.UP:
print("向上移动")
Direction.DOWN:
print("向下移动")
# ...
# 枚举也像字典一样可以获取名称
print(Direction.keys()[Direction.UP]) # "UP"
2.6 @export 变量:在编辑器中调整数值
这是一个非常实用的功能。通过在变量前添加 @export,你可以在 Godot 编辑器的检视面板中直接修改这个变量的值:
extends CharacterBody2D
@export var speed: int = 200 # 在编辑器中可见并可修改
@export var jump_force: float = 400.0
@export var player_name: String = "Hero"
@export var start_position: Vector2 = Vector2(100, 200)
# 导出范围限制
@export_range(1, 100) var health: int = 50
@export_range(0.0, 1.0) var opacity: float = 1.0
# 导出文件路径
@export_file("*.tscn") var next_scene: String
@export_dir var save_directory: String
# 导出颜色
@export_color_no_alpha var tint: Color = Color.WHITE
这使得你可以快速调整游戏参数而不需要修改代码,极大地提高了迭代效率。
2.7 静态类型的好处
虽然 GDScript 是动态类型语言,但强烈推荐使用静态类型注解:
好处:
- 编辑器能提供更好的代码补全
- 在编译时发现类型错误,而不是运行时
- 代码更易读、自文档化
- 性能略有提升
# 不推荐(但可以工作)
var damage = calculate_damage()
# 推荐
var damage: int = calculate_damage()
# 函数参数和返回值也推荐添加类型
func add_numbers(a: int, b: int) -> int:
return a + b
2.8 类型转换
有时你需要将一个类型转换为另一个类型:
# 显式转换
var int_value: int = 42
var float_value: float = float(int_value) # 42.0
var float_num: float = 3.14
var int_num: int = int(float_num) # 3(截断,不是四舍五入)
# 字符串转换
var num_str: String = "123"
var num: int = int(num_str) # 123
var bad: int = int("hello") # 0(转换失败返回0)
var number: int = 456
var str_num: String = str(number) # "456"
# 使用 as 关键字进行安全的类型转换(针对对象类型)
var node: Node = $MyNode
var label: Label = node as Label
if label:
label.text = "转换成功"
2.9 练习
练习2.1:编写一个脚本,定义以下变量:玩家姓名(字符串)、等级(整数)、经验值(浮点数)、是否在线(布尔值)。给它们赋初值,然后用 print() 输出一条包含所有信息的消息。
练习2.2:创建一个计算器脚本,定义两个整数变量 a 和 b,计算并输出它们的和、差、积、商、整数商和余数。
练习2.3:使用 @export 创建一个角色属性设置器,包含:生命值(1-500)、移动速度(50-800)、角色颜色(带透明度)、初始武器名称(字符串)。在编辑器中观察它们如何出现在检视面板中。
第3章:流程控制
程序默认是按顺序从上到下执行的。流程控制语句让你能够改变这个执行顺序。
3.1 条件判断:if / elif / else
条件判断让程序能够根据不同的情况执行不同的代码:
var health = 30
if health <= 0:
print("玩家死亡")
elif health < 30:
print("血量低,小心!")
elif health < 70:
print("血量中等")
else:
print("血量充足")
# 输出:"血量低,小心!"
语法要点:
if后面跟条件表达式,以冒号:结尾- 条件为真时执行的代码块必须缩进(推荐4个空格)
elif是 "else if" 的缩写,可以有多个else是可选的,处理所有其他情况
简洁写法(单行):
# 只有一行代码时可以写在同一行
if game_over: print("游戏结束")
# 三元运算符(条件 ? 真值 : 假值)
var status = "成人" if age >= 18 else "未成年"
3.2 循环
3.2.1 for 循环
for 循环用于遍历一个范围(range)或集合中的每个元素:
# 遍历数字范围
for i in range(5): # 0, 1, 2, 3, 4
print(i)
for i in range(2, 5): # 2, 3, 4
print(i)
for i in range(0, 10, 2): # 0, 2, 4, 6, 8(步长为2)
print(i)
# 倒序遍历
for i in range(5, 0, -1): # 5, 4, 3, 2, 1
print(i)
# 遍历数组
var fruits = ["苹果", "香蕉", "橙子"]
for fruit in fruits:
print(fruit)
# 遍历字典
var player = {"name": "勇者", "hp": 100, "mp": 50}
for key in player:
print(key, ":", player[key])
# 同时获取索引和值
for index in range(fruits.size()):
print(index, fruits[index])
3.2.2 while 循环
while 循环在条件为真时重复执行:
var count = 0
while count < 5:
print("计数:", count)
count += 1
# 无限循环(小心使用!)
var running = true
var timer = 0
while running:
timer += 1
if timer >= 100:
running = false # 退出循环
# 通常更安全的做法是使用 _process 或 Timer 节点
3.2.3 循环控制:break 和 continue
break:立即退出整个循环continue:跳过当前迭代,继续下一次循环
# 查找第一个偶数
for i in range(10):
if i % 2 == 0:
print("找到偶数:", i)
break # 找到后就退出,不再继续
# 跳过奇数
for i in range(10):
if i % 2 == 1:
continue # 奇数跳过
print(i) # 只打印偶数
3.3 match 语句(模式匹配)
match 是 GDScript 中的强大功能,类似于其他语言的 switch,但更强大:
var command = "HEAL"
match command:
"ATTACK":
print("执行攻击")
"DEFEND":
print("执行防御")
"HEAL":
print("执行治疗")
_: # 下划线是默认情况
print("未知命令")
# match 支持多种模式
var value = 42
match value:
0:
print("是零")
1, 2, 3: # 多个值匹配同一个分支
print("1到3之间")
10..20: # 范围匹配
print("10到20之间(包含边界)")
var v: # 绑定到变量
print("其他值: ", v)
# 匹配类型
var node = get_node("SomeNode")
match typeof(node):
TYPE_LABEL:
print("这是一个 Label")
TYPE_BUTTON:
print("这是一个 Button")
3.4 常见模式与实践
3.4.1 保护子句(提前返回)
使用 if 提前处理边界情况,减少嵌套:
# 不推荐:深层嵌套
func process_player(player):
if player != null:
if player.is_alive:
if player.has_weapon:
player.attack()
else:
print("没有武器")
else:
print("玩家已死亡")
else:
print("玩家不存在")
# 推荐:使用保护子句
func process_player(player):
if player == null:
print("玩家不存在")
return
if not player.is_alive:
print("玩家已死亡")
return
if not player.has_weapon:
print("没有武器")
return
player.attack()
3.4.2 使用枚举代替魔法数字
# 不推荐
if state == 0: # 0代表什么?
jump()
elif state == 1: # 1呢?
run()
# 推荐
enum PlayerState {IDLE, RUN, JUMP, FALL}
if state == PlayerState.JUMP:
jump()
elif state == PlayerState.RUN:
run()
3.5 练习
练习3.1:写一个判断成绩等级的程序。输入一个分数(0-100),输出对应的等级:90分以上为A,80-89为B,70-79为C,60-69为D,60分以下为F。
练习3.2:用循环计算 1 到 100 之间所有偶数的和。
练习3.3:用 match 语句实现一个简单的计算器,根据操作符("+"、"-"、"*"、"/")执行相应的运算。
第4章:函数与方法
4.1 定义和调用函数
函数是组织代码的基本单元。它们让你能够将重复使用的代码打包,并赋予一个名字。
# 基本函数定义
func say_hello():
print("Hello!")
# 带参数的函数
func greet(name: String):
print("Hello, ", name)
# 带返回值的函数
func add(a: int, b: int) -> int:
return a + b
# 使用默认参数
func create_player(name: String = "Newbie", health: int = 100) -> Dictionary:
return {"name": name, "health": health}
# 调用函数
say_hello() # Hello!
greet("Alice") # Hello, Alice
var result = add(5, 3) # result = 8
var player = create_player() # 使用默认值
var custom = create_player("战神", 500)
4.2 返回值与 return
return 语句有两重作用:
- 从函数返回一个值
- 立即结束函数的执行
func check_health(health: int) -> String:
if health <= 0:
return "dead" # 这里就返回了,下面的代码不会执行
elif health < 30:
return "critical"
return "healthy" # 如果没进任何分支,到这里返回
# 没有显式 return 的函数返回 null
func do_nothing():
pass
var nothing = do_nothing() # nothing = null
4.3 函数是一等公民
在 GDScript 中,函数可以被赋值给变量,作为参数传递:
# 将函数赋值给变量
var my_func = say_hello
my_func.call() # 调用函数
# 更常见的做法:使用 Callable
var callable: Callable = say_hello
callable.call()
# 将函数作为参数传递
func apply_operation(a: int, b: int, operation: Callable) -> int:
return operation.call(a, b)
func multiply(x: int, y: int) -> int:
return x * y
var result = apply_operation(5, 3, multiply) # 15
# 匿名函数(Lambda)
var add_lambda = func(a, b): return a + b
print(add_lambda.call(2, 3)) # 5
4.4 内置的常用函数
GDScript 提供了许多内置函数,可以直接使用:
# 类型转换
int("123") # 123
float("3.14") # 3.14
str(42) # "42"
# 数学函数
abs(-5) # 5
min(10, 3) # 3
max(10, 3) # 10
clamp(15, 0, 10) # 10(限制在0-10之间)
round(3.6) # 4
floor(3.9) # 3
ceil(3.1) # 4
sqrt(16) # 4
pow(2, 3) # 8
# 随机数
randi() # 随机整数
randf() # 0.0 到 1.0 之间的随机浮点数
randi_range(1, 10) # 1到10之间的随机整数
# 设置随机种子,使随机结果可重现
seed(12345)
# 类型检查
is_instance_of(node, Label) # 检查 node 是否是 Label 的实例
typeof(value) # 返回值的类型
4.5 静态函数
使用 static 关键字创建属于类本身而不是实例的函数:
class_name MathHelper
static func square(x: float) -> float:
return x * x
static func is_even(x: int) -> bool:
return x % 2 == 0
# 调用静态函数(不需要实例)
var result = MathHelper.square(5) # 25
var even = MathHelper.is_even(10) # true
4.6 函数的递归
函数调用自身称为递归:
# 计算阶乘
func factorial(n: int) -> int:
if n <= 1:
return 1
return n * factorial(n - 1)
print(factorial(5)) # 120
# 小心:深度递归可能导致栈溢出
# 对于大量迭代,使用循环通常更好
4.7 练习
练习4.1:编写一个函数 is_prime(n),判断一个整数是否为质数。然后在 _ready() 中测试 1 到 100 之间的所有整数。
练习4.2:编写一个函数 fibonacci(n),返回斐波那契数列的第 n 项(使用循环实现,不是递归)。
练习4.3:创建一个 Calculator 类,包含静态方法 add、subtract、multiply、divide,以及一个计算平方根的静态方法 sqrt。
第5章:数组与字典
5.1 数组(Array)
数组是存储有序元素集合的数据结构。在 GDScript 中,数组可以混合存储不同类型的元素。
# 创建数组
var empty_array = [] # 空数组
var numbers = [1, 2, 3, 4, 5] # 整数数组
var mixed = [1, "hello", 3.14, true] # 混合类型
# 类型化数组(性能更好,类型安全)
var typed_array: Array[int] = [1, 2, 3]
var strings: Array[String] = ["a", "b", "c"]
# 访问元素(索引从0开始)
var fruits = ["苹果", "香蕉", "橙子"]
print(fruits[0]) # "苹果"
print(fruits[2]) # "橙子"
print(fruits[-1]) # "橙子"(负数从末尾开始)
# 修改元素
fruits[1] = "葡萄"
print(fruits) # ["苹果", "葡萄", "橙子"]
# 数组大小
print(fruits.size()) # 3
# 遍历数组
for fruit in fruits:
print(fruit)
for i in range(fruits.size()):
print(i, fruits[i])
5.2 数组常用方法
var arr = [1, 2, 3]
# 添加元素
arr.append(4) # [1, 2, 3, 4]
arr.append(5) # [1, 2, 3, 4, 5]
arr.push_front(0) # [0, 1, 2, 3, 4, 5](效率较低)
# 插入元素
arr.insert(3, 99) # 在索引3处插入99 → [0, 1, 2, 99, 3, 4, 5]
# 移除元素
arr.remove_at(3) # 移除索引3 → [0, 1, 2, 3, 4, 5]
arr.pop_back() # 移除最后一个元素,返回它 → 5, arr变为[0,1,2,3,4]
arr.pop_front() # 移除第一个元素 → 0, arr变为[1,2,3,4]
# 查找元素
var idx = arr.find(3) # 返回3的索引(2)
var has_5 = 5 in arr # false(检查是否包含)
# 排序
var unsorted = [3, 1, 4, 1, 5]
unsorted.sort() # [1, 1, 3, 4, 5](原地排序)
# 反转
unsorted.reverse() # [5, 4, 3, 1, 1]
# 复制数组
var copy = arr.duplicate() # 浅复制
var deep_copy = arr.duplicate(true) # 深复制(复制嵌套的数组/字典)
# 清空
arr.clear() # []
# 切片
var slice = [0, 1, 2, 3, 4, 5]
var sub = slice.slice(2, 5) # [2, 3, 4](索引2到5,不包括5)
# 连接
var combined = [1, 2] + [3, 4] # [1, 2, 3, 4]
5.3 字典(Dictionary)
字典存储键值对。键可以是任何类型,但通常使用字符串。
# 创建字典
var empty_dict = {}
var player = {
"name": "勇者",
"hp": 100,
"mp": 50,
"inventory": ["剑", "盾", "药水"],
"position": Vector2(100, 200)
}
# 访问值
print(player["name"]) # "勇者"
print(player.name) # 也可以使用点号(如果键是有效标识符)
# 添加/修改值
player["level"] = 5 # 添加新键值对
player.hp = 80 # 修改现有值
player["mp"] -= 10 # 减少mp
# 检查键是否存在
if player.has("inventory"):
print("有背包")
# 或者使用 in 操作符
if "level" in player:
print("等级:", player.level)
# 删除键值对
player.erase("mp") # 删除mp键
# 遍历字典
for key in player:
print(key, ":", player[key])
# 同时遍历键和值
for key in player.keys():
print(key)
for value in player.values():
print(value)
# 获取键列表或值列表
var keys = player.keys() # ["name", "hp", ...]
var values = player.values()
# 字典大小
print(player.size()) # 键值对的数量
# 合并字典
var dict1 = {"a": 1, "b": 2}
var dict2 = {"b": 3, "c": 4}
dict1.merge(dict2) # dict1变成{"a":1, "b":3, "c":4}(重复键被覆盖)
# 复制
var dict_copy = player.duplicate()
var deep_copy = player.duplicate(true)
5.4 多维数组与嵌套字典
# 二维数组(网格)
var grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
print(grid[1][2]) # 6(第2行,第3列)
# 访问所有元素
for row in grid:
for cell in row:
print(cell)
# 嵌套字典
var game_data = {
"player": {
"name": "英雄",
"stats": {
"strength": 10,
"dexterity": 8,
"intelligence": 7
}
},
"world": {
"name": "中土",
"difficulty": "normal"
}
}
print(game_data.player.stats.strength) # 10
5.5 数组与字典的性能考虑
# 数组查找是 O(n)(线性时间)
var items = ["sword", "shield", "potion", ...] # 可能有1000个元素
if "excalibur" in items: # 需要遍历整个数组才能确定
print("找到")
# 字典查找是 O(1)(常数时间)
var item_dict = {
"sword": true,
"shield": true,
"potion": true,
# ...
}
if item_dict.has("excalibur"): # 直接哈希查找,非常快
print("找到")
# 所以如果需要频繁查找,使用字典更好
# 如果需要保持顺序,使用数组
5.6 练习
练习5.1:创建一个待办事项管理器。使用数组存储任务(字符串),实现添加任务、完成(删除)任务、列出所有任务的功能。
练习5.2:用字典表示一个游戏角色,包含以下属性:名称、等级、生命值、魔法值、装备列表(数组)、技能字典(技能名→等级)。然后编写函数显示角色的完整信息。
练习5.3:写一个函数,接受一个整数数组,返回一个新数组,其中只包含原数组中的偶数,并按升序排列。
第6章:节点与场景
这是 GDScript 区别于普通编程语言的核心内容。理解节点和场景是掌握 Godot 的关键。
6.1 节点树
在 Godot 中,每个游戏都是由节点(Node)组成的树状结构:
Scene (根节点)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ └── CollisionShape2D
├── Enemy (CharacterBody2D)
│ ├── Sprite2D
│ └── CollisionShape2D
└── UI (CanvasLayer)
├── Label
└── Button
6.2 获取节点
要从脚本中访问其他节点,你需要获取对它们的引用:
extends Node2D
func _ready():
# 方法1:使用 $ 语法(推荐)
var player = $Player
var sprite = $Player/Sprite2D
# 方法2:使用 get_node()
var enemy = get_node("Enemy")
var ui = get_node("../UI") # 父目录
# 方法3:使用路径
var absolute = get_node("/root/Main/Player") # 从根节点开始的绝对路径
# 方法4:使用 % 和唯一名称
# 在节点上启用"Access as Unique Name"后,可以使用 % 快速访问
var health_label = %HealthLabel # 节点唯一名称为"HealthLabel"
# 方法5:查找子节点
var child = find_child("Sprite2D") # 递归查找
var children = get_children() # 获取所有直接子节点
# 检查节点是否存在
if has_node("Player"):
var player = $Player
player.move()
# 安全的节点获取
var node = get_node_or_null("MaybeNotExist")
if node:
node.do_something()
6.3 节点生命周期函数
这些是 Godot 中最重要的内置函数,记住它们的调用顺序:
extends Node
# 1. 构造函数(不能访问其他节点,因为节点还未加入场景树)
func _init():
print("1. 初始化")
# 这里不能使用 $ 或 get_node()!
# 2. 进入场景树时调用
func _enter_tree():
print("2. 进入场景树")
# 现在可以使用 get_node() 了,但子节点可能还没准备好
# 3. 所有子节点都进入场景树后调用(最常用)
func _ready():
print("3. 准备就绪")
# 最安全的地方:所有子节点都已准备好
# 4. 每帧调用(帧速率相关)
func _process(delta: float):
# delta 是上一帧到这一帧经过的时间(秒)
# 用于实现与帧率无关的移动
pass
# 5. 每个物理帧调用(固定时间间隔,默认60次/秒)
func _physics_process(delta: float):
# 用于物理相关代码(移动、碰撞等)
pass
# 6. 输入事件(键盘、鼠标、触摸)
func _input(event: InputEvent):
if event.is_action_pressed("ui_accept"):
print("按下了确认键")
# 7. 离开场景树时调用
func _exit_tree():
print("7. 离开场景树")
6.4 _process() vs _physics_process()
这是初学者最容易混淆的地方:
extends CharacterBody2D
@export var speed = 300
# _process: 每帧调用,帧率变化时调用频率也会变化
func _process(delta):
# 不适合处理物理移动,因为帧率波动会导致移动不平滑
# 但适合处理动画、UI更新、非物理相关逻辑
update_ui()
# _physics_process: 固定频率(默认60Hz),与物理引擎同步
func _physics_process(delta):
# 适合处理角色移动、碰撞检测等物理相关逻辑
var velocity = Vector2.ZERO
if Input.is_action_pressed("ui_right"):
velocity.x += 1
if Input.is_action_pressed("ui_left"):
velocity.x -= 1
velocity = velocity.normalized() * speed
move_and_collide(velocity * delta)
# 或使用 move_and_slide()
选择指南:
- 使用
_physics_process:角色移动、物理交互、碰撞检测 - 使用
_process:动画更新、UI 闪烁效果、非关键计时、Camera 平滑跟随
6.5 场景切换
游戏通常由多个场景组成(主菜单、游戏关卡、设置界面等):
extends Node
# 切换到另一个场景
func go_to_game():
# 方法1:直接切换(推荐)
get_tree().change_scene_to_file("res://scenes/Game.tscn")
# 方法2:先加载,再切换(可以显示加载界面)
ResourceLoader.load_threaded_request("res://scenes/Game.tscn")
var game_scene = await ResourceLoader.load_threaded_get("res://scenes/Game.tscn")
get_tree().change_scene_to_packed(game_scene)
# 重新加载当前场景
func restart_scene():
get_tree().reload_current_scene()
# 添加场景作为子节点(而不是替换)
func add_pause_menu():
var pause_scene = load("res://scenes/PauseMenu.tscn").instantiate()
add_child(pause_scene)
# 场景切换时的数据传递
var player_data = {
"score": 1000,
"lives": 3
}
# 在切换前将数据保存到某个地方(比如单例)
func save_and_switch():
Global.player_data = player_data
get_tree().change_scene_to_file("res://scenes/NextLevel.tscn")
# 在新场景的 _ready() 中读取数据
func _ready():
if Global.player_data:
score = Global.player_data.score
lives = Global.player_data.lives
6.6 节点创建与删除
动态创建和销毁节点是常见需求:
# 创建节点
func spawn_enemy():
# 加载场景
var enemy_scene = preload("res://scenes/Enemy.tscn")
# 实例化
var enemy = enemy_scene.instantiate()
# 设置属性
enemy.position = Vector2(100, 200)
enemy.name = "Enemy_" + str(get_child_count())
# 添加到场景树
add_child(enemy)
# 使用代码创建基本节点
func create_custom_node():
var label = Label.new()
label.text = "动态创建的标签"
label.position = Vector2(50, 50)
add_child(label)
# 删除节点
func remove_enemy(enemy: Node):
enemy.queue_free() # 安全删除(在当前帧结束时删除)
# enemy.free() # 立即删除(可能导致错误,不推荐)
# 删除所有子节点
func clear_children():
for child in get_children():
child.queue_free()
# 等待一帧后再删除(有时需要)
func delayed_remove(node: Node, delay: float = 0.1):
await get_tree().create_timer(delay).timeout
if is_instance_valid(node):
node.queue_free()
6.7 练习
练习6.1:创建一个场景,包含一个玩家(CharacterBody2D 带 Sprite2D 子节点)和几个金币(Area2D)。编写脚本,当玩家碰到金币时,金币消失并增加分数。使用 _physics_process 处理玩家移动。
练习6.2:实现一个简单的场景切换系统:按空格键切换到另一个场景,按 R 键重新开始当前场景。创建两个不同的场景来测试。
练习6.3:创建一个弹幕发射器。使用 _process 每0.5秒动态创建一个子弹节点(场景实例),子弹自动向前移动,到达屏幕边缘后自动销毁。
第7章:信号(Signals)
信号是 Godot 中最优雅的通信机制。它们实现了松耦合的组件间通信,让你无需知道接收者是谁就能发送消息。
7.1 理解信号
想象一下:你按下一个按钮,想要更新屏幕上的分数。按钮不需要知道分数显示在哪里,它只需要发出“我被按下了”的信号。任何关心这个信号的节点都可以连接并响应。
这就是信号的哲学:发送者不关心接收者,接收者决定关心什么事件。
7.2 使用内置信号
大多数 Godot 节点都提供了内置信号:
extends Node
func _ready():
# 连接按钮的 pressed 信号
var button = $Button
button.pressed.connect(_on_button_pressed)
# 连接计时器的 timeout 信号
var timer = $Timer
timer.timeout.connect(_on_timer_timeout)
timer.start(2.0) # 2秒后触发
func _on_button_pressed():
print("按钮被按下了!")
func _on_timer_timeout():
print("2秒钟过去了!")
更简洁的写法:在编辑器中连接信号
- 选中节点
- 转到“节点”面板(Node)
- 找到信号
- 双击或拖拽到目标节点
- 选择或创建连接函数
Godot 会自动生成类似这样的代码:
# 编辑器会自动生成连接和回调函数
func _on_button_pressed():
# 你的代码
pass
7.3 自定义信号
你可以定义自己的信号来满足特定需求:
extends CharacterBody2D
# 定义信号
signal health_changed(new_health: int, max_health: int)
signal player_died
signal score_updated(new_score: int)
signal item_collected(item_name: String, value: int)
var health: int = 100
var max_health: int = 100
var score: int = 0
func take_damage(amount: int):
health -= amount
# 发出信号
health_changed.emit(health, max_health)
if health <= 0:
die()
func add_score(points: int):
score += points
score_updated.emit(score)
func collect_item(item_name: String, value: int):
item_collected.emit(item_name, value)
add_score(value)
func die():
player_died.emit()
queue_free()
7.4 连接信号的方式
有几种方式连接信号:
# 方式1:使用 .connect() 方法
func _ready():
$Button.pressed.connect(_on_button_pressed)
$Player.health_changed.connect(_on_health_changed)
# 方式2:使用 Callable 绑定参数
func _ready():
$Button.pressed.connect(_on_button_pressed.bind("额外参数"))
func _on_button_pressed(extra_param: String):
print("按钮被按下,额外参数:", extra_param)
# 方式3:连接带参数的信号
signal damage_dealt(amount: int, target: String)
func _ready():
damage_dealt.connect(_on_damage_dealt)
func _on_damage_dealt(amount: int, target: String):
print("对 ", target, " 造成了 ", amount, " 点伤害")
# 方式4:使用 lambda 表达式(不推荐用于重要逻辑)
func _ready():
$Button.pressed.connect(func(): print("按钮被按下"))
7.5 信号的高级用法
7.5.1 断开连接
# 断开特定连接
if $Button.pressed.is_connected(_on_button_pressed):
$Button.pressed.disconnect(_on_button_pressed)
# 断开所有连接(谨慎使用)
$Button.pressed.disconnect_all()
7.5.2 信号连接状态
# 检查是否已连接
if $Button.pressed.is_connected(_on_button_pressed):
print("已经连接了")
# 获取连接数量
print($Button.pressed.get_connection_count())
# 获取所有连接信息
var connections = $Button.pressed.get_connections()
for conn in connections:
print("连接到: ", conn.callable)
7.5.3 等待信号
使用 await 关键字等待信号:
# 等待计时器
func wait_example():
print("开始等待")
await get_tree().create_timer(2.0).timeout
print("2秒后执行这里")
# 等待动画完成
func play_and_wait():
var animation = $AnimationPlayer
animation.play("jump")
await animation.animation_finished
print("动画播放完毕")
# 等待任意信号
func wait_for_button():
print("等待按钮按下...")
await $Button.pressed
print("按钮被按下了!")
# 等待多个信号中的任意一个
func wait_for_any():
var button_pressed = $Button.pressed
var timer_timeout = $Timer.timeout
var result = await await_first(button_pressed, timer_timeout)
print("最先发生的是: ", result)
func await_first(signal1: Signal, signal2: Signal):
var result = await signal1
return "signal1"
# 注意:这个实现不完美,实际需要更复杂的逻辑
7.5.4 信号的常见模式
观察者模式:
# GameManager.gd (自动加载单例)
extends Node
signal game_started
signal game_over(score: int)
signal level_completed(level: int)
var current_score: int = 0
func start_game():
current_score = 0
game_started.emit()
func end_game():
game_over.emit(current_score)
# UI.gd
func _ready():
GameManager.game_started.connect(_on_game_started)
GameManager.game_over.connect(_on_game_over)
func _on_game_started():
show_hud()
reset_display()
func _on_game_over(score: int):
show_game_over_screen(score)
中介者模式:
# LevelManager.gd
extends Node
signal all_enemies_defeated
var enemy_count: int = 0
func register_enemy(enemy: Node):
enemy_count += 1
enemy.died.connect(_on_enemy_died)
func _on_enemy_died():
enemy_count -= 1
if enemy_count <= 0:
all_enemies_defeated.emit()
7.6 信号 vs 直接调用
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 父子节点通信 | 直接调用 | 关系明确,性能更好 |
| 兄弟节点通信 | 信号 | 避免耦合,更灵活 |
| 任意节点通信 | 信号 | 发送者不需要知道接收者 |
| 频繁调用(每帧) | 直接调用 | 信号有轻微开销 |
| 跨场景通信 | 信号(通过单例) | 解耦,易于维护 |
| 编辑器内连接 | 信号 | 可视化,直观 |
7.7 练习
练习7.1:创建一个健康系统。玩家节点有一个 health 变量,当受到伤害时发出 damage_taken 信号。创建一个 UI 节点连接到这个信号,更新生命条显示。
练习7.2:实现一个成就系统。当玩家收集特定数量的金币(发出 coin_collected 信号)时,发出 achievement_unlocked 信号。创建一个成就弹窗节点监听此信号并显示通知。
练习7.3:使用 await 创建一个序列:玩家碰到门 → 等待1秒 → 播放传送动画 → 等待动画完成 → 切换场景。
第8章:基础游戏组件
8.1 CharacterBody2D:角色控制
CharacterBody2D 是制作可移动角色的专用节点:
extends CharacterBody2D
@export var speed: float = 300.0
@export var jump_velocity: float = -400.0
# 获取重力(从项目设置)
var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
func _physics_process(delta):
# 应用重力
if not is_on_floor():
velocity.y += gravity * delta
# 处理跳跃
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = jump_velocity
# 处理水平输入
var direction = Input.get_axis("ui_left", "ui_right")
if direction:
velocity.x = direction * speed
else:
velocity.x = move_toward(velocity.x, 0, speed)
move_and_slide()
8.2 Area2D:区域检测
Area2D 用于检测重叠(不需要物理碰撞响应):
extends Area2D
signal coin_collected
func _ready():
# 连接信号(也可以在编辑器中连接)
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D):
if body.is_in_group("player"):
coin_collected.emit()
queue_free()
8.3 Timer:计时器
extends Node
@onready var timer = $Timer
func start_countdown(seconds: float):
timer.wait_time = seconds
timer.one_shot = true # 单次触发
timer.start()
await timer.timeout
print("倒计时结束!")
func start_repeating():
timer.wait_time = 1.0
timer.one_shot = false # 重复触发
timer.start()
func _on_timer_timeout():
print("每一秒执行一次")
8.4 练习
练习8.1:创建一个完整的平台跳跃角色,包含移动、跳跃、二段跳和爬墙功能。
练习8.2:创建一个收集品系统,有不同价值的金币(1分、5分、10分),使用 Area2D 检测玩家,更新 UI 显示总分。
第二部分:进阶篇
第9章:场景树与自动加载
9.1 场景树的深层理解
Godot 的场景树不仅仅是节点的容器,它是一个活动对象的层次结构:
# 获取场景树的根节点
var root = get_tree().root
# 获取当前场景
var current_scene = get_tree().current_scene
# 场景树遍历
func traverse_tree(node: Node, indent: int = 0):
print(" ".repeat(indent), node.name)
for child in node.get_children():
traverse_tree(child, indent + 1)
# 调用场景树中的所有节点
get_tree().call_group("enemies", "take_damage", 10)
# 设置组
func _ready():
add_to_group("enemies")
# 暂停游戏
func toggle_pause():
get_tree().paused = not get_tree().paused
# 在暂停状态下让某个节点继续工作
func _ready():
process_mode = PROCESS_MODE_ALWAYS # 即使游戏暂停也继续处理
9.2 自动加载(单例模式)
自动加载的脚本会在游戏启动时加载,并且全局可用:
设置方法:
- 项目设置 → 自动加载(AutoLoad)
- 添加脚本或场景
- 命名(如 "Global")
Global.gd:
extends Node
# 全局变量
var player_score: int = 0
var current_level: int = 1
var player_data: Dictionary = {}
# 全局信号
signal score_changed(new_score: int)
signal level_completed
# 全局函数
func add_score(points: int):
player_score += points
score_changed.emit(player_score)
func save_game():
var save_dict = {
"score": player_score,
"level": current_level,
"player_data": player_data
}
var file = FileAccess.open("user://savegame.save", FileAccess.WRITE)
file.store_string(JSON.stringify(save_dict))
func load_game():
if FileAccess.file_exists("user://savegame.save"):
var file = FileAccess.open("user://savegame.save", FileAccess.READ)
var content = file.get_as_text()
var data = JSON.parse_string(content)
player_score = data["score"]
current_level = data["level"]
player_data = data["player_data"]
在任何脚本中使用:
# 直接使用,无需获取引用
Global.add_score(100)
Global.save_game()
print(Global.player_score)
9.3 资源的预加载与加载
# 预加载:在编译时加载(速度快,但增加启动时间)
const EnemyScene = preload("res://scenes/Enemy.tscn")
# 运行时加载(稍慢,但更灵活)
var scene_path = "res://scenes/Level" + str(level_num) + ".tscn"
var level_scene = load(scene_path)
# 异步加载(避免卡顿)
func load_level_async(level_path: String):
var load_thread = ResourceLoader.load_threaded_request(level_path)
# 可以做其他事情
await get_tree().create_timer(0.1).timeout
var scene = await ResourceLoader.load_threaded_get(level_path)
get_tree().change_scene_to_packed(scene)
9.4 练习
练习9.1:创建一个 SaveSystem 自动加载单例,实现完整的游戏存档功能,包括保存玩家的位置、分数和背包物品。
练习9.2:实现一个暂停菜单,使用 get_tree().paused,并确保暂停菜单本身仍然可以响应输入。
第10章:输入处理
10.1 Input Map(输入映射)
在项目设置中定义输入动作,让输入处理与具体按键解耦:
设置输入动作:
移动_左: [A, 左箭头]
移动_右: [D, 右箭头]
跳跃: [Space, 上箭头]
攻击: [鼠标左键, J]
使用输入动作:
func _process(delta):
# 检查按住
var left = Input.is_action_pressed("移动_左")
var right = Input.is_action_pressed("移动_右")
# 检查按下(单次)
if Input.is_action_just_pressed("跳跃"):
jump()
# 检查释放
if Input.is_action_just_released("攻击"):
stop_attacking()
10.2 自定义 InputEvent 处理
func _input(event: InputEvent):
# 处理鼠标
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
print("鼠标点击位置: ", event.position)
# 处理键盘
if event is InputEventKey:
if event.pressed and event.keycode == KEY_ESCAPE:
toggle_pause_menu()
# 处理游戏手柄
if event is InputEventJoypadButton:
if event.button_index == JOY_BUTTON_A:
print("A 按钮被按下")
# 手势(触摸屏)
if event is InputEventScreenTouch:
if event.pressed:
print("触摸点: ", event.position)
10.3 练习
练习10.1:创建一个可配置的控制设置界面,允许玩家修改按键绑定,并将配置保存到文件中。
第11章:性能优化
11.1 避免在 _process 中重复操作
# 不推荐
func _process(delta):
var children = get_children() # 每帧都获取
for child in children:
child.update()
# 推荐:缓存引用
var children_cache: Array
func _ready():
children_cache = get_children()
func _process(delta):
for child in children_cache:
child.update()
11.2 使用对象池
class_name ObjectPool
var pool: Array = []
var scene: PackedScene
func _init(scene_path: String, initial_size: int = 10):
scene = preload(scene_path)
for i in range(initial_size):
var obj = scene.instantiate()
obj.visible = false
add_child(obj)
pool.append(obj)
func get_object():
if pool.is_empty():
var obj = scene.instantiate()
add_child(obj)
return obj
else:
var obj = pool.pop_back()
obj.visible = true
return obj
func return_object(obj: Node):
obj.visible = false
pool.append(obj)
11.3 练习
练习11.1:实现一个子弹对象池,支持回收和复用,对比使用对象池前后的性能差异。
第12章:设计模式在 GDScript 中的应用
12.1 状态模式
extends CharacterBody2D
var state_machine: StateMachine
func _ready():
state_machine = StateMachine.new(self)
state_machine.add_state("idle", IdleState.new())
state_machine.add_state("run", RunState.new())
state_machine.add_state("jump", JumpState.new())
state_machine.change_to("idle")
func _physics_process(delta):
state_machine.update(delta)
# 状态基类
class State:
var character: CharacterBody2D
func enter(): pass
func exit(): pass
func update(delta): pass
# 具体状态
class IdleState extends State:
func enter():
character.velocity = Vector2.ZERO
func update(delta):
if Input.get_axis("ui_left", "ui_right") != 0:
state_machine.change_to("run")
if Input.is_action_just_pressed("ui_accept"):
state_machine.change_to("jump")
12.2 观察者模式
# 使用信号实现观察者模式
class Subject:
signal value_changed(new_value)
var _value: int = 0
func set_value(v: int):
_value = v
value_changed.emit(v)
class Observer:
func on_value_changed(v: int):
print("值变为: ", v)
# 使用
var subject = Subject.new()
var observer = Observer.new()
subject.value_changed.connect(observer.on_value_changed)
subject.set_value(42)
12.3 练习
练习12.1:使用状态模式实现一个敌人 AI,包含巡逻、追逐、攻击和逃跑四种状态。
第三部分:高级篇
第13章:自定义资源
13.1 创建自定义资源
# ItemResource.gd
extends Resource
class_name ItemResource
@export var item_name: String = ""
@export var description: String = ""
@export var icon: Texture2D
@export var value: int = 0
@export var item_type: ItemType
enum ItemType {CONSUMABLE, EQUIPMENT, QUEST, MATERIAL}
func use(target: Node) -> bool:
# 具体使用效果由子类实现
return false
13.2 练习
练习13.1:创建一个完整的物品系统,包括武器、防具、消耗品等自定义资源,并实现背包系统。
第14章:工具脚本与编辑器集成
14.1 @tool 脚本
@tool
extends Node2D
@export var radius: float = 50.0:
set(value):
radius = value
queue_redraw()
func _draw():
draw_circle(Vector2.ZERO, radius, Color.YELLOW)
func _ready():
if Engine.is_editor_hint():
print("运行在编辑器中")
else:
print("运行在游戏中")
14.2 练习
练习14.1:创建一个自定义的路径绘制工具,在编辑器中可视化显示 NPC 巡逻路径。
第15章:网络编程
15.1 基础网络
extends Node
var peer: ENetMultiplayerPeer
func create_server():
peer = ENetMultiplayerPeer.new()
peer.create_server(4242)
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_player_connected)
print("服务器已启动")
func join_server(ip: String = "127.0.0.1"):
peer = ENetMultiplayerPeer.new()
peer.create_client(ip, 4242)
multiplayer.multiplayer_peer = peer
@rpc
func send_message(content: String):
print("收到消息: ", content)
15.2 练习
练习15.1:实现一个简单的多人游戏,玩家可以移动和发射子弹,其他玩家实时看到。
第16章:完整游戏项目实战
16.1 项目规划
让我们创建一个完整的“吸血鬼幸存者”风格的俯视角射击游戏:
核心功能:
- 玩家移动和射击
- 敌人 AI(追逐玩家)
- 经验值和升级系统
- 多种武器和技能
- Boss 战
16.2 实现要点
# 玩家控制器
extends CharacterBody2D
@export var speed: float = 300.0
var health: int = 100
var experience: int = 0
var level: int = 1
func _physics_process(delta):
var input_dir = Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = input_dir * speed
move_and_slide()
# 面向鼠标方向
look_at(get_global_mouse_position())
func take_damage(amount: int):
health -= amount
if health <= 0:
die()
else:
# 无敌帧和闪白效果
modulate = Color.RED
await get_tree().create_timer(0.1).timeout
modulate = Color.WHITE
16.3 完整项目结构
res://
├── scenes/
│ ├── Main.tscn
│ ├── Player.tscn
│ ├── Enemy.tscn
│ ├── Weapon.tscn
│ └── UI.tscn
├── scripts/
│ ├── Player.gd
│ ├── Enemy.gd
│ ├── Weapon.gd
│ ├── ExperienceGem.gd
│ └── UpgradeSystem.gd
├── resources/
│ ├── weapons/
│ ├── enemies/
│ └── upgrades/
├── assets/
│ ├── sprites/
│ └── sounds/
└── autoload/
├── Global.gd
└── GameManager.gd
附录
附录A:常见错误与解决方案
| 错误 | 原因 | 解决方法 |
|---|---|---|
| "Invalid call. Nonexistent function" | 函数名拼写错误或作用域错误 | 检查函数名,确认对象类型 |
| "Attempt to call function in base" | 节点类型转换错误 | 使用 as 或 is_instance_of() 检查 |
| "Resourceloader: Cannot load file" | 路径错误 | 使用 res:// 绝对路径 |
| "Queue_free() on null instance" | 对象已被删除 | 使用 is_instance_valid() 检查 |
附录B:GDScript 2.0 新特性
@export变量的改进- 匿名函数(Lambda)
await关键字替代yield- 类型化数组
Array[Type] - 字符串格式化增强
附录C:推荐的资源
- 官方文档:docs.godotengine.org
- GDQuest 教程:gdquest.com
- Godot 演示项目:github.com/godotengine/godot-demo-projects
- Reddit 社区:r/godot
结语
恭喜你完成了《GDScript 从浅入深》的学习之旅!
从第一行的 "Hello World",到复杂的网络游戏架构,你已经掌握了使用 GDScript 开发游戏的核心技能。但请记住,编程是一项实践技能——只有不断地编写代码、犯错、调试、改进,才能真正的进步。
GDScript 的设计哲学是让你专注于游戏逻辑本身,而不是与语言搏斗。随着你的深入,你会发现它的简洁背后蕴藏着强大的表现力。
现在,是时候去创造你自己的游戏了。从一个小项目开始,比如一个简单的平台跳跃游戏,或者一个休闲的益智游戏。使用你学到的知识:用信号来解耦组件,用状态机来管理复杂的角色行为,用自动加载来管理全局状态。
如果你在开发中遇到问题,Godot 社区非常友好和乐于助人。不要害怕提问,也不要害怕犯错——每一个错误都是学习的机会。
最后,记住:游戏开发的终极目标是创造乐趣。技术只是实现这个目标的工具。享受编码的过程,也享受创造的乐趣。
祝你在 Godot 和 GDScript 的世界中玩得开心!

浙公网安备 33010602011771号