Godot 4.6.2 — 2026 年最新

Godot 4.6 完整中文教程
从零到独立开发游戏

面向只会 Python 基础语法的小白,通过一个完整的文字卡牌游戏《命运之牌》,手把手带你掌握 Godot 游戏开发。

目标读者  Python 基础小白 引擎版本  Godot 4.6.2 实战项目  文字卡牌游戏 预计时长  20-30 小时

第一部分:认识 Godot

了解引擎、安装环境、熟悉编辑器、理解核心概念

1.1 Godot 是什么?

Godot(读作"戈多")是一款完全免费、开源的游戏引擎。你用它做的游戏,100% 归你所有,不需要付任何费用或版税。

为什么选 Godot?

特点说明
完全免费MIT 协议,商业游戏也无需付费
轻量级安装包约 50-80MB,不需要额外安装运行时
GDScript内置脚本语言,语法极像 Python,上手极快
跨平台一次开发,可导出到 Windows、Mac、Linux、Android、iOS、Web
全能引擎2D 和 3D 游戏都能做
中文社区活跃的中文社区和文档

和 Python 的相似度

如果你会 Python,学 GDScript 几乎没有门槛:

Python
# Python def greet(name): if name == "": print("请输入名字") else: print("你好," + name)
GDScript
# GDScript —— 几乎一模一样! func greet(name): if name == "": print("请输入名字") else: print("你好," + name)
主要区别就是 def 变成了 func,其他语法(缩进、冒号、if/else)完全一致。

1.2 下载与安装

第一步:下载

  1. 打开官网:godotengine.org/download
  2. 选择你的操作系统(Windows / macOS / Linux)
  3. 下载 Godot Engine 标准版(不需要选 .NET 版,那是给 C# 用的)
提示:Windows 用户下载后得到一个 .exe 文件,不需要安装,双击就能运行!

第二步:启动

首次启动会看到项目管理器,这是管理所有游戏项目的地方。

第三步:创建第一个项目

  1. 点击 "新建项目"
  2. 项目名称:我的第一个游戏
  3. 项目路径:选一个你记得住的文件夹
  4. 渲染器:选 "兼容(Compatibility)"(适合 2D 游戏,兼容性最好)
  5. 点击 "创建并编辑"

1.3 编辑器界面导览

进入编辑器后,你会看到这样的布局:

┌─────────────────────────────────────────────────────────┐ │ 菜单栏:场景 / 项目 / 调试 / 编辑器 / 帮助 │ │ 工具栏:▶运行 ▶场景 ⏸暂停 ⏹停止 │ ├──────────┬──────────────────────────┬───────────────────┤ │ │ │ │ │ 场景 │ │ 检查器 │ │ 面板 │ 2D/3D 视口 │ (Inspector) │ │ │ (主编辑区域) │ 显示选中节点 │ │ 显示 │ │ 的所有属性 │ │ 场景树 │ │ │ │ 结构 │ ├───────────────────┤ │ │ │ 节点 (Node) │ ├──────────┼──────────────────────────┤ 显示信号和组 │ │ 文件 │ 底部面板 │ │ │ 系统 │ 输出 / 调试器 / 搜索 │ │ └──────────┴──────────────────────────┴───────────────────┘

四大核心面板

面板位置作用
场景面板左上显示当前场景的节点树结构,类似 HTML 的 DOM 树
文件系统左下管理项目中的所有文件(脚本、图片、音效等)
检查器右侧编辑选中节点的属性(位置、大小、颜色等)
视口中央2D/3D 场景的可视化编辑区域

常用快捷键

快捷键功能
F5运行整个项目
F6运行当前场景
F8停止运行
Ctrl+S保存场景
Ctrl+Shift+S另存为场景
Ctrl+Z撤销
Ctrl+N新建场景

1.4 核心概念:节点、场景、场景树

这是 Godot 最重要的三个概念,理解它们就理解了 Godot 的一半。

节点(Node)—— 游戏的最小积木块

节点是 Godot 中一切事物的基本单位。每个节点做一件事:

Label → 显示文字 Button → 可点击的按钮 Sprite2D → 显示图片 AudioStreamPlayer → 播放音效 Timer → 定时器

类比:把节点想象成乐高积木,每块积木有不同形状和功能。

场景(Scene)—— 由节点组成的整体

场景是一组节点的组合,保存为 .tscn 文件。

Card (PanelContainer) ← 卡牌背景 ├── VBoxContainer ← 垂直排列子节点 │ ├── NameLabel (Label) ← 卡牌名称 │ ├── DescLabel (Label) ← 卡牌描述 │ └── CostLabel (Label) ← 费用显示

关键概念:场景可以嵌套!一个"手牌区"场景可以包含多个"卡牌"场景。

场景树(SceneTree)—— 运行中的游戏世界

SceneTree(场景树) └── root(根节点,Viewport) └── Main(你的主场景) ├── UI │ ├── HealthBar │ └── HandArea │ ├── Card1 │ ├── Card2 │ └── Card3 └── BattleArea └── Enemy
🧱
类比总结
  • 节点 = 乐高积木
  • 场景 = 用积木拼好的一个模型
  • 场景树 = 把所有模型组装在一起的展览台

用 Python 思维理解

Python
# 如果 Godot 是 Python,它大概是这样的: class Node: def __init__(self, name): self.name = name self.children = [] def add_child(self, node): self.children.append(node) # 创建场景 card = Node("Card") card.add_child(Node("NameLabel")) card.add_child(Node("DescLabel")) # 场景树 root = Node("Root") main = Node("Main") root.add_child(main) main.add_child(card)

第二部分:GDScript 快速入门

每个语法点先展示 Python 写法,再展示 GDScript 写法

2.1 变量与数据类型

声明变量

Python
name = "勇者" health = 100 speed = 3.5 is_alive = True
GDScript
var name = "勇者" # 自动推断 var health = 100 var speed = 3.5 var is_alive = true # 小写 # 显式指定类型(推荐) var name: String = "勇者" var health: int = 100 var speed: float = 3.5 var is_alive: bool = true

常量

Python
MAX_HEALTH = 100 # 约定大写,但可被修改
GDScript
const MAX_HEALTH = 100 # 真正的常量

数据类型对照表

PythonGDScript说明
intint整数
floatfloat浮点数
strString字符串(注意大写 S)
boolbool布尔值(true/false 小写)
listArray数组
dictDictionary字典
Nonenull空值

类型推断语法糖

GDScript
# 用 := 可以自动推断类型,同时锁定类型 var health := 100 # 推断为 int,之后不能赋值为字符串 var name := "勇者" # 推断为 String

2.2 函数

基本函数

Python
def attack(damage): print("造成了 " + str(damage) + " 点伤害") return damage * 2 result = attack(10)
GDScript
func attack(damage): print("造成了 " + str(damage) + " 点伤害") return damage * 2 var result = attack(10)

带类型标注的函数(推荐)

GDScript
func attack(damage: int) -> int: print("造成了 " + str(damage) + " 点伤害") return damage * 2

Godot 生命周期函数

这些是 Godot 自动调用的特殊函数,类似 Python 的 __init__

GDScript
# 节点进入场景树时调用一次(类似 __init__) func _ready(): print("我准备好了!") # 每帧调用一次(每秒约 60 次) # delta 是距离上一帧的时间(秒) func _process(delta: float): position.x += speed * delta # 物理帧调用(固定频率,默认每秒 60 次) func _physics_process(delta: float): pass # 处理输入事件 func _input(event: InputEvent): if event is InputEventMouseButton: print("鼠标点击了!")
🔄
生命周期顺序
节点被创建 ↓ _ready() ← 初始化代码写这里 ↓ _process(delta) ← 每帧循环(游戏主循环) _input(event) ← 有输入时触发 ↓ _exit_tree() ← 节点被移除时调用

2.3 条件判断

Python
if health <= 0: print("你死了") elif health < 30: print("危险!") else: print("状态良好")
GDScript
# 完全一样的结构! if health <= 0: print("你死了") elif health < 30: print("危险!") else: print("状态良好")

match 语句

GDScript
var card_type = "attack" match card_type: "attack": print("这是攻击牌") "defense": print("这是防御牌") "heal": print("这是治疗牌") _: print("未知类型") # _ 类似 default

2.4 循环

Python
for i in range(5): print(i) for card in hand: print(card.name) while health > 0: health -= 10
GDScript
# 完全一致! for i in range(5): print(i) for card in hand: print(card.name) while health > 0: health -= 10

GDScript 特有的循环技巧

GDScript
# 遍历字典 var stats = {"攻击": 10, "防御": 5, "速度": 3} for key in stats: print(key, ": ", stats[key]) # 遍历节点的所有子节点 for child in get_children(): print(child.name)

2.5 数组与字典

数组(Array)

Python
cards = ["火球", "冰箭", "治疗"] cards.append("闪电") first = cards[0] length = len(cards) cards.remove("冰箭")
GDScript
var cards = ["火球", "冰箭", "治疗"] cards.append("闪电") var first = cards[0] var length = cards.size() # 不是 len() cards.erase("冰箭") # 不是 remove()

常用数组方法对照

PythonGDScript说明
len(arr)arr.size()获取长度
arr.append(x)arr.append(x)添加元素
arr.remove(x)arr.erase(x)按值删除
arr.pop(i)arr.pop_at(i)按索引删除
x in arrarr.has(x)检查是否包含
arr.sort()arr.sort()排序
arr[1:3]arr.slice(1,3)切片
random.shuffle(arr)arr.shuffle()随机打乱
random.choice(arr)arr.pick_random()随机选一个

字典(Dictionary)

Python
player = { "name": "勇者", "health": 100, "attack": 15 } print(player["name"]) player["defense"] = 10
GDScript
var player = { "name": "勇者", "health": 100, "attack": 15 } print(player["name"]) # 或 player.name player["defense"] = 10

2.6 字符串操作

GDScript
var name = "勇者" var damage = 25 # 方式一:拼接 print("玩家 " + name + " 造成了 " + str(damage) + " 点伤害") # 方式二:格式化(类似 Python 的 %) print("玩家 %s 造成了 %d 点伤害" % [name, damage]) # 字符串方法 var text = " Hello World " text.strip_edges() # Python 的 strip() text.to_upper() # 转大写 text.to_lower() # 转小写 text.split(" ") # 分割 text.length() # 长度

2.7 类与继承

在 Godot 中,每个脚本文件就是一个类。通常不需要写 class 关键字:

card.gd
# card.gd —— 这个文件本身就是 Card 类 extends Resource var card_name: String var cost: int var damage: int func _init(p_name: String = "", p_cost: int = 0, p_damage: int = 0): card_name = p_name cost = p_cost damage = p_damage func play(): print("打出 %s,造成 %d 点伤害" % [card_name, damage])
fire_card.gd
# fire_card.gd —— 继承 Card extends "res://card.gd" func _init(): super("火球", 2, 30) func play(): super.play() print("附加燃烧效果!")

给类起名字(方便引用)

GDScript
# card.gd class_name Card extends Resource # 其他脚本中可以直接用 Card var my_card = Card.new() my_card.card_name = "冰箭"

2.8 Godot 特有语法

@export —— 在编辑器中暴露变量

GDScript
@export var max_health: int = 100 @export var card_name: String = "默认卡牌" # 更多 @export 类型 @export_range(0, 100, 1) var health: int = 50 # 滑动条 @export_enum("攻击", "防御", "治疗") var type: int # 下拉菜单 @export_multiline var description: String # 多行文本 @export_group("战斗属性") # 分组

@onready —— 安全地获取子节点

GDScript
@onready var name_label = $NameLabel # $ 是获取子节点的简写 @onready var health_bar = $UI/HealthBar # 路径访问嵌套节点 @onready var cards = $HandArea.get_children() # 获取所有子节点
$ 语法详解$NodeNameget_node("NodeName") 的简写。路径用 / 分隔。

信号(Signal)—— 事件驱动通信

GDScript
# ===== 发送方:card.gd ===== signal card_played(card_data) signal card_hovered func on_click(): card_played.emit(self) # ===== 接收方:battle.gd ===== func _ready(): var card = $HandArea/Card1 card.card_played.connect(_on_card_played) func _on_card_played(card_data): print("玩家打出了一张牌:", card_data.card_name)

枚举(enum)

GDScript
enum CardType { ATTACK, DEFENSE, HEAL, SPECIAL } enum Rarity { COMMON, RARE, EPIC, LEGENDARY } match my_type: CardType.ATTACK: print("攻击牌") CardType.DEFENSE: print("防御牌") CardType.HEAL: print("治疗牌")

await —— 异步等待

GDScript
func play_card_animation(): $AnimationPlayer.play("card_flip") await $AnimationPlayer.animation_finished print("动画播完了!") func wait_and_do(): print("等一下...") await get_tree().create_timer(2.0).timeout print("2 秒后!")

第三部分:Godot 核心系统

节点、场景、信号、资源、UI、输入、音频、持久化

3.1 节点系统

基础节点

节点用途类比
Node最基础的节点,纯逻辑容器Python 的 object
Node2D2D 游戏对象基类
Node3D3D 游戏对象基类

UI 节点(Control 家族)—— 卡牌游戏最常用

节点用途HTML 类比
ControlUI 基类<div>
Label显示文字<span>
Button按钮<button>
TextureRect显示图片<img>
PanelContainer带背景的面板有背景色的 div
VBoxContainer子节点垂直排列flex-direction: column
HBoxContainer子节点水平排列flex-direction: row
GridContainer网格排列display: grid
MarginContainer添加边距padding
RichTextLabel富文本富文本编辑器
ProgressBar进度条<progress>

容器布局

VBoxContainer(垂直排列) HBoxContainer(水平排列) ┌──────────────┐ ┌──────────────────────────┐ │ Child 1 │ │ Child1 │ Child2 │ Child3 │ │ Child 2 │ └──────────────────────────┘ │ Child 3 │ └──────────────┘ GridContainer(网格,columns=3) ┌────────┬────────┬────────┐ │ Cell 1 │ Cell 2 │ Cell 3 │ ├────────┼────────┼────────┤ │ Cell 4 │ Cell 5 │ Cell 6 │ └────────┴────────┴────────┘

节点操作代码

GDScript
# 获取节点 var label = $NameLabel # 通过 $ 获取子节点 var parent = get_parent() # 获取父节点 var children = get_children() # 获取所有子节点 # 添加/移除节点 var new_label = Label.new() # 代码创建节点 new_label.text = "你好" add_child(new_label) # 添加为子节点 new_label.queue_free() # 安全销毁 # 实例化场景 var card_scene = preload("res://scenes/card.tscn") var card = card_scene.instantiate() $HandArea.add_child(card)

3.2 场景系统

GDScript
# 加载场景 var scene = preload("res://scenes/card.tscn") # 编译时加载(推荐) var scene = load("res://scenes/card.tscn") # 运行时加载 # 实例化 var instance = scene.instantiate() add_child(instance) # 切换场景 get_tree().change_scene_to_file("res://scenes/battle.tscn")
📁
res:// 路径说明

res:// 代表项目根目录。如 res://scenes/main.tscn 对应项目文件夹中的 scenes/main.tscn


3.3 信号系统详解

📡
通信原则

"调用向下,信号向上":父节点直接调用子节点方法;子节点发信号通知父节点;兄弟节点通过父节点中转。

GDScript
# 内置信号 func _ready(): $StartButton.pressed.connect(_on_start_pressed) $CooldownTimer.timeout.connect(_on_cooldown_end) # 自定义信号 signal health_changed(new_health: int) signal card_played(card: Resource, target: Node) func take_damage(amount: int): health -= amount health_changed.emit(health) # 一次性连接 player.died.connect(_on_player_died, CONNECT_ONE_SHOT)

3.4 资源系统(Resource)

Resource 是 Godot 中管理游戏数据的核心类——非常适合卡牌数据:

card_data.gd
class_name CardData extends Resource @export var card_name: String = "" @export_multiline var description: String = "" @export var cost: int = 1 @export var damage: int = 0 @export var icon: Texture2D

在编辑器中:右键 → 新建资源 → 搜索 CardData → 保存为 .tres 文件,在检查器中填写属性。

GDScript
# 代码中加载和使用资源 var fireball = preload("res://data/cards/fireball.tres") as CardData print(fireball.card_name) # "火球术" print(fireball.damage) # 30

3.5 UI 系统

锚点与 StyleBox

GDScript
# 创建带圆角、阴影的面板样式 var style = StyleBoxFlat.new() style.bg_color = Color(0.2, 0.2, 0.3, 1.0) style.corner_radius_top_left = 10 style.corner_radius_top_right = 10 style.corner_radius_bottom_left = 10 style.corner_radius_bottom_right = 10 style.border_width_left = 2 style.border_color = Color(0.8, 0.7, 0.3) $PanelContainer.add_theme_stylebox_override("panel", style)

3.6 输入系统

GDScript
# 方式一:直接检测 func _input(event): if event is InputEventMouseButton and event.pressed: print("鼠标点击位置:", event.position) # 方式二:使用 InputMap(推荐) # 项目 → 项目设置 → 输入映射 中定义动作 func _input(event): if event.is_action_pressed("play_card"): play_selected_card() if event.is_action_pressed("end_turn"): end_turn()

3.7 音频系统

GDScript
# 播放音效 $ClickSound.play() # 代码加载并播放 var sound = preload("res://assets/audio/click.wav") $ClickSound.stream = sound $ClickSound.play() # 调整音量(分贝) $BGM.volume_db = -5

3.8 数据持久化(存档/读档)

GDScript
# ===== 保存存档 ===== func save_game(): var save_data = { "player_health": player.health, "player_gold": player.gold, "current_level": current_level, } var file = FileAccess.open("user://save_game.json", FileAccess.WRITE) file.store_string(JSON.stringify(save_data, "\t")) # ===== 读取存档 ===== func load_game(): if not FileAccess.file_exists("user://save_game.json"): return var file = FileAccess.open("user://save_game.json", FileAccess.READ) var json = JSON.new() if json.parse(file.get_as_text()) == OK: var data = json.data player.health = data["player_health"]
user:// 路径指向用户数据目录(Windows: %APPDATA%/Godot/app_userdata/项目名/),不同于 res://

第四部分:实战 —— 文字卡牌游戏《命运之牌》

从零开始创建一个完整的卡牌战斗游戏

游戏设计文档

项目说明
游戏名命运之牌
类型单人回合制文字卡牌
核心玩法每回合从牌堆抽牌、使用行动点打出卡牌、击败敌人

界面布局预览

┌─────────────────────────────────────┐ │ 敌人名称 [敌人生命条] │ │ 意图:将要攻击 15 点 │ ├─────────────────────────────────────┤ │ │ │ 战 斗 日 志 │ │ "你打出了火球术,造成 30 伤害" │ │ "哥布林攻击了你,造成 15 伤害" │ │ │ ├─────────────────────────────────────┤ │ 行动点:●●●○○ [结束回合] │ ├─────────────────────────────────────┤ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │火球术│ │寒冰盾│ │治疗术│ │重击 │ │ │ │费用:2│ │费用:1│ │费用:1│ │费用:3│ │ │ │伤害30│ │护甲15│ │回血20│ │伤害50│ │ │ └─────┘ └─────┘ └─────┘ └─────┘ │ ├─────────────────────────────────────┤ │ 玩家生命:80/100 ■■■■■■■■░░ │ └─────────────────────────────────────┘
1
创建项目
  1. 打开 Godot → 新建项目 → 项目名称:命运之牌
  2. 渲染器:兼容 (Compatibility)
  3. 项目设置:视口 1280×720,拉伸模式 canvas_items

项目文件结构

res:// ├── scenes/ ← 场景文件 ├── scripts/ ← 脚本文件 ├── data/ │ └── cards/ ← 卡牌数据资源 ├── assets/ │ ├── audio/ ← 音效 │ └── fonts/ ← 字体 └── theme/ ← 主题资源
2
设计卡牌数据结构
scripts/card_data.gd
class_name CardData extends Resource enum Type { ATTACK, DEFEND, HEAL, SPECIAL } enum Rarity { COMMON, UNCOMMON, RARE } @export var card_name: String = "未命名" @export_multiline var description: String = "" @export var cost: int = 1 @export var card_type: Type = Type.ATTACK @export var rarity: Rarity = Rarity.COMMON @export_group("效果数值") @export var damage: int = 0 @export var block: int = 0 @export var heal: int = 0 @export var draw_cards: int = 0

初始卡牌列表

名称类型费用伤害护甲治疗
打击攻击18
重击攻击218
火球术攻击330
防御防御18
铁壁防御218
治疗术治疗110
强效治疗治疗222
攻守兼备特殊21010
3
制作卡牌 UI 场景

节点结构

Card (PanelContainer) — 自定义最小尺寸 180×240 ├── MarginContainer — 全屏,边距 12 │ └── VBoxContainer │ ├── HeaderHBox (HBoxContainer) │ │ ├── NameLabel (Label) — 填充展开,左对齐 │ │ └── CostLabel (Label) — 右对齐 │ ├── HSeparator │ ├── DescLabel (Label) — 自动换行,垂直展开 │ └── StatsHBox (HBoxContainer) │ ├── DamageLabel (Label) — 居中 │ ├── BlockLabel (Label) — 居中 │ └── HealLabel (Label) — 居中

保存为 res://scenes/card.tscn

4
为卡牌编写脚本
scripts/card.gd
extends PanelContainer signal card_clicked(card_node: PanelContainer) var card_data: CardData var playable: bool = true : set(value): playable = value _update_appearance() @onready var name_label: Label = $MarginContainer/VBoxContainer/HeaderHBox/NameLabel @onready var cost_label: Label = $MarginContainer/VBoxContainer/HeaderHBox/CostLabel @onready var desc_label: Label = $MarginContainer/VBoxContainer/DescLabel @onready var damage_label: Label = $MarginContainer/VBoxContainer/StatsHBox/DamageLabel @onready var block_label: Label = $MarginContainer/VBoxContainer/StatsHBox/BlockLabel @onready var heal_label: Label = $MarginContainer/VBoxContainer/StatsHBox/HealLabel func _ready(): mouse_entered.connect(_on_mouse_entered) mouse_exited.connect(_on_mouse_exited) func setup(data: CardData) -> void: card_data = data if not is_node_ready(): await ready _update_display() func _update_display() -> void: if card_data == null: return name_label.text = card_data.card_name cost_label.text = "[%d]" % card_data.cost desc_label.text = card_data.description damage_label.text = "伤害:%d" % card_data.damage if card_data.damage > 0 else "" block_label.text = "护甲:%d" % card_data.block if card_data.block > 0 else "" heal_label.text = "治疗:%d" % card_data.heal if card_data.heal > 0 else "" func _update_appearance() -> void: modulate = Color(1,1,1,1) if playable else Color(0.5,0.5,0.5,1) func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed: if event.button_index == MOUSE_BUTTON_LEFT and playable: card_clicked.emit(self) func _on_mouse_entered() -> void: if playable: scale = Vector2(1.05, 1.05) z_index = 10 func _on_mouse_exited() -> void: scale = Vector2(1.0, 1.0) z_index = 0
5
创建手牌与牌堆系统
scripts/game_manager.gd
class_name GameManager extends Node signal hand_changed signal deck_changed signal action_points_changed(current: int) const MAX_HAND_SIZE := 10 const DRAW_PER_TURN := 5 const MAX_ACTION_POINTS := 3 var deck: Array[CardData] = [] var hand: Array[CardData] = [] var discard_pile: Array[CardData] = [] var action_points: int = MAX_ACTION_POINTS func init_deck() -> void: deck.clear(); hand.clear(); discard_pile.clear() _add_cards("打击", "基础攻击", CardData.Type.ATTACK, 1, 8, 0, 0, 4) _add_cards("防御", "获得护甲", CardData.Type.DEFEND, 1, 0, 8, 0, 4) _add_cards("火球术", "释放火球", CardData.Type.ATTACK, 2, 18, 0, 0, 1) _add_cards("治疗术", "恢复生命", CardData.Type.HEAL, 1, 0, 0, 8, 1) deck.shuffle() func _add_cards(n: String, desc: String, type: CardData.Type, cost: int, dmg: int, blk: int, hl: int, count: int) -> void: for i in range(count): var c = CardData.new() c.card_name = n; c.description = desc; c.card_type = type c.cost = cost; c.damage = dmg; c.block = blk; c.heal = hl deck.append(c) func draw_cards(count: int) -> Array[CardData]: var drawn: Array[CardData] = [] for i in range(count): if hand.size() >= MAX_HAND_SIZE: break if deck.is_empty(): _reshuffle() if deck.is_empty(): break var card = deck.pop_back() hand.append(card); drawn.append(card) hand_changed.emit(); deck_changed.emit() return drawn func _reshuffle() -> void: deck = discard_pile.duplicate() discard_pile.clear(); deck.shuffle() func play_card(card: CardData) -> bool: if card.cost > action_points: return false action_points -= card.cost hand.erase(card); discard_pile.append(card) action_points_changed.emit(action_points); hand_changed.emit() return true func start_turn() -> void: action_points = MAX_ACTION_POINTS action_points_changed.emit(action_points) draw_cards(DRAW_PER_TURN) func end_turn() -> void: discard_pile.append_array(hand); hand.clear(); hand_changed.emit() func can_play(card: CardData) -> bool: return card.cost <= action_points
6
实现战斗系统
scripts/battle_manager.gd
class_name BattleManager extends Node signal player_health_changed(current: int, max_hp: int) signal player_block_changed(block: int) signal enemy_health_changed(current: int, max_hp: int) signal enemy_intent_changed(text: String) signal battle_log_added(text: String) signal battle_won signal battle_lost var player_max_health := 80 var player_health := 80 var player_block := 0 var enemy_name := "哥布林" var enemy_max_health := 50 var enemy_health := 50 var enemy_min_damage := 8 var enemy_max_damage := 16 var enemy_intent_damage := 0 func init_battle(name: String, hp: int, min_d: int, max_d: int) -> void: enemy_name = name; enemy_max_health = hp; enemy_health = hp enemy_min_damage = min_d; enemy_max_damage = max_d player_health = player_max_health; player_block = 0 _roll_intent() player_health_changed.emit(player_health, player_max_health) enemy_health_changed.emit(enemy_health, enemy_max_health) func apply_card(card: CardData) -> void: if card.damage > 0: enemy_health = max(0, enemy_health - card.damage) _log("你打出【%s】,造成 %d 点伤害!" % [card.card_name, card.damage]) enemy_health_changed.emit(enemy_health, enemy_max_health) if card.block > 0: player_block += card.block _log("你打出【%s】,获得 %d 点护甲。" % [card.card_name, card.block]) player_block_changed.emit(player_block) if card.heal > 0: player_health = min(player_max_health, player_health + card.heal) _log("你打出【%s】,回复生命。" % card.card_name) player_health_changed.emit(player_health, player_max_health) if enemy_health <= 0: _log("%s被击败了!" % enemy_name); battle_won.emit() func enemy_turn() -> void: var dmg = enemy_intent_damage if player_block > 0: if player_block >= dmg: player_block -= dmg; dmg = 0 _log("护甲吸收了全部伤害。") else: dmg -= player_block; player_block = 0 if dmg > 0: player_health = max(0, player_health - dmg) _log("你受到了 %d 点伤害!" % dmg) player_health_changed.emit(player_health, player_max_health) player_block_changed.emit(player_block) if player_health <= 0: battle_lost.emit(); return player_block = 0; player_block_changed.emit(0) _roll_intent() func _roll_intent() -> void: enemy_intent_damage = randi_range(enemy_min_damage, enemy_max_damage) enemy_intent_changed.emit("意图:攻击 %d 点" % enemy_intent_damage) func _log(text: String) -> void: battle_log_added.emit(text)
7
创建主战斗场景

完整节点结构

BattleScene (Control) — 全屏 ├── Background (ColorRect) — #1a1a2e ├── MainLayout (VBoxContainer) — 全屏 │ ├── EnemyArea (PanelContainer) │ │ └── EnemyVBox │ │ ├── EnemyNameLabel │ │ ├── EnemyHealthBar (ProgressBar) │ │ └── EnemyIntentLabel │ ├── BattleLogPanel — 垂直展开 │ │ └── ScrollContainer │ │ └── BattleLogLabel (RichTextLabel) │ ├── InfoBar (HBoxContainer) │ │ ├── ActionPointsLabel — 展开 │ │ ├── DeckCountLabel │ │ └── EndTurnButton │ ├── HandArea (HBoxContainer) — 卡牌在此动态添加 │ └── PlayerArea (PanelContainer) │ └── PlayerHBox │ ├── PlayerHealthLabel │ ├── PlayerHealthBar │ └── PlayerBlockLabel ├── GameManager (Node) ├── BattleManager (Node) └── ResultPanel — 默认隐藏

保存为 res://scenes/battle_scene.tscn

8
编写战斗场景脚本
scripts/battle_scene.gd
extends Control var card_scene: PackedScene = preload("res://scenes/card.tscn") @onready var game_mgr: GameManager = $GameManager @onready var battle_mgr: BattleManager = $BattleManager @onready var enemy_name_label = $MainLayout/EnemyArea/EnemyVBox/EnemyNameLabel @onready var enemy_health_bar = $MainLayout/EnemyArea/EnemyVBox/EnemyHealthBar @onready var enemy_intent_label = $MainLayout/EnemyArea/EnemyVBox/EnemyIntentLabel @onready var battle_log = $MainLayout/BattleLogPanel/ScrollContainer/BattleLogLabel @onready var action_pts_label = $MainLayout/InfoBar/ActionPointsLabel @onready var deck_label = $MainLayout/InfoBar/DeckCountLabel @onready var end_turn_btn = $MainLayout/InfoBar/EndTurnButton @onready var hand_area = $MainLayout/HandArea @onready var hp_label = $MainLayout/PlayerArea/PlayerHBox/PlayerHealthLabel @onready var hp_bar = $MainLayout/PlayerArea/PlayerHBox/PlayerHealthBar @onready var block_label = $MainLayout/PlayerArea/PlayerHBox/PlayerBlockLabel @onready var result_panel = $ResultPanel @onready var result_label = $ResultPanel/ResultVBox/ResultLabel @onready var result_btn = $ResultPanel/ResultVBox/ResultButton var battle_active := false func _ready(): # 连接所有信号 game_mgr.hand_changed.connect(_refresh_hand) game_mgr.deck_changed.connect(_update_deck) game_mgr.action_points_changed.connect(_update_ap) battle_mgr.player_health_changed.connect(_update_hp) battle_mgr.player_block_changed.connect(_update_block) battle_mgr.enemy_health_changed.connect(_update_enemy_hp) battle_mgr.enemy_intent_changed.connect(_update_intent) battle_mgr.battle_log_added.connect(_add_log) battle_mgr.battle_won.connect(_on_win) battle_mgr.battle_lost.connect(_on_lose) end_turn_btn.pressed.connect(_on_end_turn) result_btn.pressed.connect(_start_game) _start_game() func _start_game(): result_panel.visible = false battle_log.clear(); battle_active = true battle_mgr.init_battle("哥布林", 50, 8, 16) enemy_name_label.text = battle_mgr.enemy_name game_mgr.init_deck() _add_log("[color=yellow]===== 战斗开始!=====") game_mgr.start_turn() func _refresh_hand(): for c in hand_area.get_children(): c.queue_free() for data in game_mgr.hand: var card = card_scene.instantiate() hand_area.add_child(card) card.setup(data) card.playable = game_mgr.can_play(data) card.card_clicked.connect(_on_card_clicked) func _on_card_clicked(card_node): if not battle_active: return if game_mgr.play_card(card_node.card_data): battle_mgr.apply_card(card_node.card_data) for c in hand_area.get_children(): if c.card_data: c.playable = game_mgr.can_play(c.card_data) func _on_end_turn(): if not battle_active: return game_mgr.end_turn() battle_mgr.enemy_turn() if battle_active: game_mgr.start_turn() # UI 更新方法省略(详见 Markdown 教程完整版) func _update_hp(cur, mx): hp_label.text = "生命:%d/%d" % [cur,mx]; hp_bar.max_value = mx; hp_bar.value = cur func _update_block(b): block_label.text = "护甲:%d" % b if b > 0 else "" func _update_enemy_hp(cur, mx): enemy_health_bar.max_value = mx; enemy_health_bar.value = cur; enemy_name_label.text = "%s HP:%d/%d" % [battle_mgr.enemy_name,cur,mx] func _update_intent(t): enemy_intent_label.text = t func _update_ap(cur): action_pts_label.text = "行动点:" + "●".repeat(cur) + "○".repeat(3 - cur) func _update_deck(): deck_label.text = "牌堆:%d | 弃牌:%d" % [game_mgr.deck.size(), game_mgr.discard_pile.size()] func _add_log(t): battle_log.append_text(t + "\n") func _on_win(): battle_active = false; result_label.text = "胜利!"; result_btn.text = "再来一局"; result_panel.visible = true func _on_lose(): battle_active = false; result_label.text = "失败……"; result_btn.text = "重新挑战"; result_panel.visible = true
9
添加主菜单
scripts/main_menu.gd
extends Control @onready var start_btn = $CenterContainer/VBoxContainer/StartButton @onready var quit_btn = $CenterContainer/VBoxContainer/QuitButton func _ready(): start_btn.pressed.connect(_on_start) quit_btn.pressed.connect(_on_quit) func _on_start(): get_tree().change_scene_to_file("res://scenes/battle_scene.tscn") func _on_quit(): get_tree().quit()

设为主场景:项目 → 项目设置 → 应用 → 运行 → 主场景 → res://scenes/main_menu.tscn

10
添加视觉反馈与打磨
卡牌样式 + 类型颜色
func _apply_style() -> void: var style = StyleBoxFlat.new() style.bg_color = Color(0.15, 0.15, 0.25) style.border_color = Color(0.6, 0.5, 0.2) style.border_width_left = 2 style.border_width_right = 2 style.border_width_top = 2 style.border_width_bottom = 2 style.corner_radius_top_left = 8 style.corner_radius_top_right = 8 style.corner_radius_bottom_left = 8 style.corner_radius_bottom_right = 8 add_theme_stylebox_override("panel", style) # 根据类型设置边框颜色 var color: Color match card_data.card_type: CardData.Type.ATTACK: color = Color(0.9, 0.3, 0.2) # 红色 CardData.Type.DEFEND: color = Color(0.5, 0.5, 0.6) # 银灰 CardData.Type.HEAL: color = Color(0.3, 0.9, 0.3) # 绿色 _: color = Color(0.8, 0.7, 0.3) # 金色

Tween 打出动画

GDScript
func _play_card_animation(card_node) -> void: var tween = create_tween() tween.set_parallel(true) tween.tween_property(card_node, "position:y", card_node.position.y - 100, 0.3) tween.tween_property(card_node, "modulate:a", 0.0, 0.3) await tween.finished
11
数据存档系统
scripts/save_system.gd
class_name SaveSystem extends Node const SAVE_PATH := "user://save_data.json" static func save_game(data: Dictionary) -> void: var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: return file.store_string(JSON.stringify(data, "\t")) static func load_game() -> Dictionary: if not FileAccess.file_exists(SAVE_PATH): return {} var file = FileAccess.open(SAVE_PATH, FileAccess.READ) var json = JSON.new() if json.parse(file.get_as_text()) == OK: return json.data return {} static func has_save() -> bool: return FileAccess.file_exists(SAVE_PATH)
12
导出游戏
  1. 编辑器 → 管理导出模板 → 下载安装
  2. 项目 → 导出 → 添加目标平台(Windows / Linux / Web)
  3. 配置产品名称和图标
  4. 点击 "导出项目"
导出 Web 版可以上传到 itch.io 分享给朋友玩!

第五部分:进阶指南与资源

FAQ、性能贴士、推荐学习资源、GDScript 速查表

5.1 常见问题 FAQ

场景中找不到节点?
GDScript
# 错误:在变量声明时获取(此时节点还没准备好) var label = $MyLabel # 可能为 null! # 正确:使用 @onready @onready var label = $MyLabel
preload 报错?
GDScript
# preload 路径必须是常量字符串 var scene = preload("res://scenes/card.tscn") # 正确 var path = "res://scenes/card.tscn" var scene = preload(path) # 错误! # 动态路径用 load() var scene = load("res://scenes/" + name + ".tscn")
$ 路径报错 "Node not found"?
GDScript
# $ 路径是相对于当前节点的 $Child/GrandChild # 正确 $GrandChild # 错误 $"../SiblingNode" # 获取兄弟节点 get_node("/root/Main/UI") # 绝对路径
中文显示异常?

Godot 4 默认支持中文。如遇问题:下载中文字体(如思源黑体)放入 assets/fonts/,在项目设置 → GUI → 主题 → 默认字体中选择。


5.2 性能小贴士

GDScript
# 1. 不需要每帧处理时关闭 _process func _ready(): set_process(false) # 需要时再 set_process(true) # 2. 预加载比运行时加载快 var scene = preload("res://card.tscn") # 推荐 var scene = load("res://card.tscn") # 较慢 # 3. 避免每帧创建临时对象 # 4. 大量重复对象考虑对象池

5.3 推荐学习资源

资源说明
Godot 官方文档(中文)最权威的学习资料
Godot GitHub源码和 Issue
Godot 资产库免费插件和模板
B 站 Godot 教程搜索"Godot 4"有大量中文视频
Reddit r/godot国际社区
🛠
建议学习路径
第一周 → 完成教程第 1-3 部分 第二周 → 跟着第 4 部分做完卡牌游戏 第三周 → 自己修改游戏(加新卡牌、新敌人) 第四周 → 尝试做一个自己的小游戏 之后 → 官方文档 + 社区 + 持续练习

5.4 GDScript 速查表

变量声明

速查
var x = 10 # 自动推断 var x: int = 10 # 显式类型 var x := 10 # 推断并锁定 const X = 10 # 常量 @export var x: int = 10 # 编辑器可见 @onready var x = $Node # ready 时初始化 static var count: int = 0 # 静态变量

生命周期

速查
func _init(): # 构造函数 func _ready(): # 进入场景树 func _process(delta): # 每帧调用 func _physics_process(delta): # 物理帧 func _input(event): # 输入事件 func _gui_input(event): # UI 输入(Control 节点) func _exit_tree(): # 离开场景树

节点与场景

速查
$NodeName # 获取子节点 get_parent() # 父节点 get_children() # 所有子节点 add_child(node) # 添加子节点 node.queue_free() # 安全销毁 var s = preload("res://x.tscn") # 预加载 var i = s.instantiate() # 实例化 get_tree().change_scene_to_file("res://x.tscn") # 切换场景 get_tree().quit() # 退出游戏

信号

速查
signal my_signal(arg: int) # 声明 my_signal.emit(10) # 发射 node.my_signal.connect(my_func) # 连接 node.my_signal.disconnect(my_func) # 断开 await node.my_signal # 等待

定时器与数学

速查
# 定时器 await get_tree().create_timer(2.0).timeout # 数学 abs(-5) # → 5 max(3, 7) # → 7 clamp(15, 0, 10) # → 10 randi_range(1, 6) # 随机 1-6 # Tween var tw = create_tween() tw.tween_property(node, "position", Vector2(100,200), 0.5) await tw.finished

恭喜你完成了教程!

你已经掌握了使用 Godot 4 创建游戏的基础知识。接下来可以尝试:

  1. 给卡牌游戏增加更多卡牌种类和效果
  2. 添加多种敌人和关卡
  3. 制作一个完全不同的游戏
  4. 阅读 Godot 官方文档深入学习

学游戏开发最好的方式就是不断做游戏。祝你创作愉快!