Godot 4.6 完整中文教程
从零到独立开发游戏
面向只会 Python 基础语法的小白,通过一个完整的文字卡牌游戏《命运之牌》,手把手带你掌握 Godot 游戏开发。
第一部分:认识 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 下载与安装
第一步:下载
- 打开官网:godotengine.org/download
- 选择你的操作系统(Windows / macOS / Linux)
- 下载 Godot Engine 标准版(不需要选 .NET 版,那是给 C# 用的)
提示:Windows 用户下载后得到一个 .exe 文件,不需要安装,双击就能运行!
第二步:启动
- Windows:双击
Godot_v4.6.2-stable_win64.exe - macOS:解压后拖入"应用程序"文件夹,双击运行
- Linux:赋予可执行权限后运行
首次启动会看到项目管理器,这是管理所有游戏项目的地方。
第三步:创建第一个项目
- 点击 "新建项目"
- 项目名称:
我的第一个游戏 - 项目路径:选一个你记得住的文件夹
- 渲染器:选 "兼容(Compatibility)"(适合 2D 游戏,兼容性最好)
- 点击 "创建并编辑"
1.3 编辑器界面导览
进入编辑器后,你会看到这样的布局:
四大核心面板
| 面板 | 位置 | 作用 |
|---|---|---|
| 场景面板 | 左上 | 显示当前场景的节点树结构,类似 HTML 的 DOM 树 |
| 文件系统 | 左下 | 管理项目中的所有文件(脚本、图片、音效等) |
| 检查器 | 右侧 | 编辑选中节点的属性(位置、大小、颜色等) |
| 视口 | 中央 | 2D/3D 场景的可视化编辑区域 |
常用快捷键
| 快捷键 | 功能 |
|---|---|
F5 | 运行整个项目 |
F6 | 运行当前场景 |
F8 | 停止运行 |
Ctrl+S | 保存场景 |
Ctrl+Shift+S | 另存为场景 |
Ctrl+Z | 撤销 |
Ctrl+N | 新建场景 |
1.4 核心概念:节点、场景、场景树
这是 Godot 最重要的三个概念,理解它们就理解了 Godot 的一半。
节点(Node)—— 游戏的最小积木块
节点是 Godot 中一切事物的基本单位。每个节点做一件事:
类比:把节点想象成乐高积木,每块积木有不同形状和功能。
场景(Scene)—— 由节点组成的整体
场景是一组节点的组合,保存为 .tscn 文件。
关键概念:场景可以嵌套!一个"手牌区"场景可以包含多个"卡牌"场景。
场景树(SceneTree)—— 运行中的游戏世界
- 节点 = 乐高积木
- 场景 = 用积木拼好的一个模型
- 场景树 = 把所有模型组装在一起的展览台
用 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 变量与数据类型
声明变量
Pythonname = "勇者" health = 100 speed = 3.5 is_alive = True
GDScriptvar 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
常量
PythonMAX_HEALTH = 100 # 约定大写,但可被修改
GDScriptconst MAX_HEALTH = 100 # 真正的常量
数据类型对照表
| Python | GDScript | 说明 |
|---|---|---|
int | int | 整数 |
float | float | 浮点数 |
str | String | 字符串(注意大写 S) |
bool | bool | 布尔值(true/false 小写) |
list | Array | 数组 |
dict | Dictionary | 字典 |
None | null | 空值 |
类型推断语法糖
GDScript# 用 := 可以自动推断类型,同时锁定类型 var health := 100 # 推断为 int,之后不能赋值为字符串 var name := "勇者" # 推断为 String
2.2 函数
基本函数
Pythondef attack(damage): print("造成了 " + str(damage) + " 点伤害") return damage * 2 result = attack(10)
GDScriptfunc attack(damage): print("造成了 " + str(damage) + " 点伤害") return damage * 2 var result = attack(10)
带类型标注的函数(推荐)
GDScriptfunc 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("鼠标点击了!")
2.3 条件判断
Pythonif health <= 0: print("你死了") elif health < 30: print("危险!") else: print("状态良好")
GDScript# 完全一样的结构! if health <= 0: print("你死了") elif health < 30: print("危险!") else: print("状态良好")
match 语句
GDScriptvar card_type = "attack" match card_type: "attack": print("这是攻击牌") "defense": print("这是防御牌") "heal": print("这是治疗牌") _: print("未知类型") # _ 类似 default
2.4 循环
Pythonfor 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)
Pythoncards = ["火球", "冰箭", "治疗"] cards.append("闪电") first = cards[0] length = len(cards) cards.remove("冰箭")
GDScriptvar cards = ["火球", "冰箭", "治疗"] cards.append("闪电") var first = cards[0] var length = cards.size() # 不是 len() cards.erase("冰箭") # 不是 remove()
常用数组方法对照
| Python | GDScript | 说明 |
|---|---|---|
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 arr | arr.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)
Pythonplayer = { "name": "勇者", "health": 100, "attack": 15 } print(player["name"]) player["defense"] = 10
GDScriptvar player = { "name": "勇者", "health": 100, "attack": 15 } print(player["name"]) # 或 player.name player["defense"] = 10
2.6 字符串操作
GDScriptvar 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() # 获取所有子节点
$ 语法详解:$NodeName是get_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)
GDScriptenum CardType { ATTACK, DEFENSE, HEAL, SPECIAL } enum Rarity { COMMON, RARE, EPIC, LEGENDARY } match my_type: CardType.ATTACK: print("攻击牌") CardType.DEFENSE: print("防御牌") CardType.HEAL: print("治疗牌")
await —— 异步等待
GDScriptfunc 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 |
Node2D | 2D 游戏对象基类 | — |
Node3D | 3D 游戏对象基类 | — |
UI 节点(Control 家族)—— 卡牌游戏最常用
| 节点 | 用途 | HTML 类比 |
|---|---|---|
Control | UI 基类 | <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> |
容器布局
节点操作代码
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://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.gdclass_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://。
第四部分:实战 —— 文字卡牌游戏《命运之牌》
从零开始创建一个完整的卡牌战斗游戏
游戏设计文档
| 项目 | 说明 |
|---|---|
| 游戏名 | 命运之牌 |
| 类型 | 单人回合制文字卡牌 |
| 核心玩法 | 每回合从牌堆抽牌、使用行动点打出卡牌、击败敌人 |
界面布局预览
- 打开 Godot → 新建项目 → 项目名称:
命运之牌 - 渲染器:兼容 (Compatibility)
- 项目设置:视口
1280×720,拉伸模式canvas_items
项目文件结构
scripts/card_data.gdclass_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
初始卡牌列表
| 名称 | 类型 | 费用 | 伤害 | 护甲 | 治疗 |
|---|---|---|---|---|---|
| 打击 | 攻击 | 1 | 8 | — | — |
| 重击 | 攻击 | 2 | 18 | — | — |
| 火球术 | 攻击 | 3 | 30 | — | — |
| 防御 | 防御 | 1 | — | 8 | — |
| 铁壁 | 防御 | 2 | — | 18 | — |
| 治疗术 | 治疗 | 1 | — | — | 10 |
| 强效治疗 | 治疗 | 2 | — | — | 22 |
| 攻守兼备 | 特殊 | 2 | 10 | 10 | — |
节点结构
保存为 res://scenes/card.tscn
scripts/card.gdextends 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
scripts/game_manager.gdclass_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
scripts/battle_manager.gdclass_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)
完整节点结构
保存为 res://scenes/battle_scene.tscn
scripts/battle_scene.gdextends 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
scripts/main_menu.gdextends 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
卡牌样式 + 类型颜色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 打出动画
GDScriptfunc _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
scripts/save_system.gdclass_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)
- 编辑器 → 管理导出模板 → 下载安装
- 项目 → 导出 → 添加目标平台(Windows / Linux / Web)
- 配置产品名称和图标
- 点击 "导出项目"
导出 Web 版可以上传到 itch.io 分享给朋友玩!
第五部分:进阶指南与资源
FAQ、性能贴士、推荐学习资源、GDScript 速查表
5.1 常见问题 FAQ
GDScript# 错误:在变量声明时获取(此时节点还没准备好) var label = $MyLabel # 可能为 null! # 正确:使用 @onready @onready var label = $MyLabel
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")
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 | 国际社区 |
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 创建游戏的基础知识。接下来可以尝试:
- 给卡牌游戏增加更多卡牌种类和效果
- 添加多种敌人和关卡
- 制作一个完全不同的游戏
- 阅读 Godot 官方文档深入学习
学游戏开发最好的方式就是不断做游戏。祝你创作愉快!