写游戏项目时,很多 Godot 新手会先急着做角色控制、战斗系统,结果做到一半才发现:玩家打开游戏的第一眼看到的却是空荡荡的 3D 世界,没有开始按钮,也没有任何操作提示。游戏主页和游戏引导,其实是决定玩家第一印象的关键模块。本文基于 Godot 3D 从零实现一套“主菜单 + 新手引导”的完整流程,包含场景搭建、UI 布局、信号连接、3D 场景切换、引导数据管理等内容,适合刚接触 Godot 的开发者参考。
1. 为什么需要游戏主页与游戏引导
1.1 游戏主页不是“一个按钮”那么简单
游戏主页(Main Menu)是玩家运行游戏后看到的第一个界面。它通常包含:
- 游戏 Logo 或背景画面
- “开始游戏”按钮
- “继续游戏”按钮(如果有存档)
- “设置”按钮
- “退出游戏”按钮
- 版本号、版权信息
在 3D 游戏中,主页可以是一张漂亮的 2D UI 面板,也可以是一个可旋转的 3D 场景。Godot 中更常见的做法是:主体用 Control 节点搭建 UI,背景用 TextureRect 展示图片,或者用 SubViewport 渲染 3D 场景作为动态背景。
主页的核心职责是“引导玩家进入游戏状态”。它需要处理好场景切换、按钮信号、输入事件以及窗口关闭逻辑。如果主页做得很粗糙,即使 3D 内容再精彩,玩家也可能在第一眼就流失。
1.2 游戏引导让玩家知道“该干嘛”
游戏引导(Tutorial)是新手教学系统。它解决以下问题:
- 玩家不知道如何移动、跳跃、交互。
- 玩家不知道当前任务目标。
- 玩家面对复杂的 UI 界面不知道从哪下手。
- 玩家容易在第一章就迷路或卡关。
好的引导不是把所有文字一次性抛给玩家,而是分步骤、按上下文、适时弹出。比如进入游戏后先显示“使用 WASD 移动”,当玩家走到某个区域后再提示“按 E 打开宝箱”。
在 Godot 3D 中,引导可以表现为:
- 屏幕中央的文本提示。
- 指向 3D 物体的箭头或高亮框。
- 任务目标 UI。
- 对话气泡。
本文会实现一个最简单的通用分步引导层,然后把它挂到 3D 游戏场景上。这样你可以在任何场景中复用同一套引导逻辑。
2. 环境准备与项目结构
2.1 Godot 版本说明
本文以Godot 4.x的 GDScript 语法为例编写代码。Godot 3.x 虽然也能实现同样的功能,但部分 API 名称不同,例如:
change_scene_to_file()在 Godot 3 中是change_scene_to()。@onready在 Godot 3 中写作onready var。- 信号连接方式在 Godot 3 中可以通过
connect()或编辑器绑定。
如果你使用的是 Godot 3.x,请对照调整。下面提供的代码重点演示设计思路,而不是死磕版本差异。
2.2 创建项目目录
打开 Godot 项目管理器,新建一个空项目。建议先规划好目录结构:
godot3d-tutorial/ ├── scenes/ │ ├── main_menu.tscn │ ├── game.tscn │ ├── player.tscn │ └── tutorial_layer.tscn ├── scripts/ │ ├── main_menu.gd │ ├── game.gd │ └── tutorial_layer.gd ├── assets/ │ ├── images/ │ └── audio/ ├── autoload/ │ └── game_state.gd └── project.godotautoload目录存放全局单例脚本,用于在场景切换时保留数据,例如“是否已经看过引导”。
3. 游戏主页(主菜单)的实现
3.1 创建主菜单场景
新建场景scenes/main_menu.tscn,根节点类型选择Control,命名为MainMenu。
场景结构如下:
MainMenu (Control) ├── Background (TextureRect) ├── CenterContainer │ └── VBoxContainer │ ├── TitleLabel (Label) │ ├── StartButton (Button) │ ├── OptionsButton (Button) │ └── QuitButton (Button)这里使用Control作为根节点,可以方便地进行 UI 布局。TextureRect可以放背景图;如果没有素材,也可以用ColorRect填充纯色背景。
在VBoxContainer中设置每个按钮的自定义最小高度,例如 60 像素,并设置字号为 24 左右。标题文字使用你游戏的实际名称,字体推荐使用支持中文的字体,否则中文字符可能显示为方块。
3.2 编写主菜单脚本
为MainMenu根节点挂载脚本main_menu.gd。
extends Control @onready var start_button: Button = $CenterContainer/VBoxContainer/StartButton @onready var options_button: Button = $CenterContainer/VBoxContainer/OptionsButton @onready var quit_button: Button = $CenterContainer/VBoxContainer/QuitButton func _ready() -> void: start_button.pressed.connect(_on_start_pressed) options_button.pressed.connect(_on_options_pressed) quit_button.pressed.connect(_on_quit_pressed) # 将鼠标设置为可见,并采用默认可视状态 Input.mouse_mode = Input.MOUSE_MODE_VISIBLE func _on_start_pressed() -> void: # 切换到主游戏场景 get_tree().change_scene_to_file("res://scenes/game.tscn") func _on_options_pressed() -> void: # 这里可以打开一个设置面板,本文省略 print("打开设置") func _on_quit_pressed() -> void: # 退出游戏;在编辑器中运行无效,发布后才能生效 get_tree().quit()代码说明:
@onready会在节点进入场景树后自动获取子节点引用。.pressed.connect()是 Godot 4 推荐的信号绑定方式。Input.mouse_mode设置为可见,避免玩家从游戏场景返回主页后鼠标被隐藏。change_scene_to_file()会卸载当前场景并加载目标场景。如果想要保留主菜单的背景音乐,可以把它放到全局单例中。
3.3 为 3D 游戏打造动态背景
如果不想用静态图片,可以创建第二个场景作为背景,然后用SubViewport渲染 3D 画面。具体做法:
- 新建一个 3D 场景,里面放一个旋转的模型。
- 在主菜单场景中添加
SubViewportContainer,再往里添加SubViewport。 - 将 3D 场景实例化为
SubViewport的子节点,并通过脚本让模型旋转。
这样做的好处是主页看起来更酷,代价是会增加性能开销。对于新手项目,建议先使用静态背景,后续再升级。
4. 游戏引导(Tutorial)层的设计
4.1 引导层的结构
新建场景scenes/tutorial_layer.tscn,根节点选择CanvasLayer。CanvasLayer的特点是它不受摄像机影响,永远显示在屏幕上层,非常适合做 UI 提示。
场景结构:
TutorialLayer (CanvasLayer) ├── Control (全屏遮挡,可选) │ └── Panel │ └── MarginContainer │ └── VBoxContainer │ ├── Label │ ├── NextButton (Button) │ └── SkipButton (Button)Control节点用于接收鼠标事件,防止玩家在引导期间点击到背后的按钮或 3D 物体。Panel是对话框面板,显示引导文本和操作按钮。
4.2 编写引导层脚本
引导层脚本的主要功能:
- 保存一个字符串数组作为步骤列表。
- 显示当前步骤文本。
- 点击“下一步”进入下一步。
- 点击“跳过”结束引导。
- 全部步骤完成后发出
tutorial_finished信号。
代码如下:
extends CanvasLayer class_name TutorialLayer signal tutorial_finished @export var steps: Array[String] = [ "欢迎来到 3D 世界!", "使用 W A S D 键控制角色移动。", "按住 Shift 可以跑步。", "靠近宝箱后按 E 打开宝箱。", "现在开始你的冒险吧!" ] var current_step: int = 0 @onready var panel: Panel = $Control/Panel @onready var label: Label = $Control/Panel/MarginContainer/VBoxContainer/Label @onready var next_button: Button = $Control/Panel/MarginContainer/VBoxContainer/NextButton @onready var skip_button: Button = $Control/Panel/MarginContainer/VBoxContainer/SkipButton func _ready() -> void: next_button.pressed.connect(_on_next_pressed) skip_button.pressed.connect(_on_skip_pressed) show_step(current_step) func show_step(index: int) -> void: if index >= steps.size(): finish_tutorial() return current_step = index label.text = steps[index] # 如果是最后一步,按钮显示“完成” if index == steps.size() - 1: next_button.text = "开始游戏" else: next_button.text = "下一步" func _on_next_pressed() -> void: show_step(current_step + 1) func _on_skip_pressed() -> void: finish_tutorial() func finish_tutorial() -> void: # 隐藏整个层,释放鼠标事件 $Control.hide() tutorial_finished.emit()说明:
@export让steps数组可以直接在编辑器里修改,从而复用同一个引导层预设不同关卡的教学内容。class_name TutorialLayer让其他脚本可以用TutorialLayer类型声明变量。finish_tutorial()发出信号后,游戏主体可以知道引导已经结束,从而解锁玩家操作或开始敌人刷新。
4.3 在游戏场景中接入引导层
假设游戏场景game.tscn中有一个角色Player,我们希望玩家未完成引导前不能移动。
在游戏场景的根节点上挂一个脚本game.gd:
extends Node3D @onready var player: CharacterBody3D = $Player @onready var tutorial: TutorialLayer = $TutorialLayer var player_control_enabled: bool = false func _ready() -> void: # 启动时暂停玩家输入 player_control_enabled = false tutorial.tutorial_finished.connect(_on_tutorial_finished) func _unhandled_input(event: InputEvent) -> void: # 玩家是否允许操作 if not player_control_enabled: return # 这里把输入事件转发给玩家 if event.is_action_pressed("move_forward"): # 实际项目中,你会在玩家的 _physics_process 中读取输入 pass func _on_tutorial_finished() -> void: player_control_enabled = true print("引导结束,玩家可以自由行动了")最简单的控制方式是:在Player脚本中使用一个布尔变量can_move,由game.gd控制。你也可以把玩家的输入处理直接放在玩家脚本里,通过公共方法启用或禁用。
5. 游戏引导与 3D 世界交互的高级设计
5.1 指向 3D 物体的引导箭头
有时我们需要引导玩家看向某个 3D 物体,比如“到前方石头那里去”。这时引导层可以计算目标物体在屏幕上的位置,然后移动箭头图标。
原理:使用摄像机将 3D 世界坐标转换为屏幕坐标,再将屏幕坐标赋值给 UI 节点。
脚本逻辑如下:
extends CanvasLayer # 在子场景中准备一个箭头箭头图标 @onready var arrow: Control = $Arrow @onready var camera: Camera3D = get_tree().get_first_node_in_group("player_camera") func _process(_delta: float) -> void: # 目标物体的全局位置 var target_pos: Vector3 = target_node.global_position # 转屏幕坐标 var screen_pos: Vector2 = camera.unproject_position(target_pos) # 如果物体在相机前方,显示箭头,否则隐藏 var camera_transform: Transform3D = camera.global_transform var to_target: Vector3 = target_pos - camera.global_position var forward: Vector3 = -camera_transform.basis.z if forward.dot(to_target) > 0: arrow.show() arrow.position = screen_pos else: arrow.hide()这个示例要求你提前在引导层里放置一个Arrow控件,并且目标物体节点存在。实际项目中,更常见的做法是让箭头出现在屏幕边缘,指向屏幕外的目标。这里只展示了基本思路。
5.2 用高亮框标注可交互物体
如果想强调“按 E 打开宝箱”,一种方法是给宝箱添加一个外发光材质或高亮轮廓。在 Godot 中可以用GeometryInstance3D的set_instance_shader_parameter配合自定义 shader 实现轮廓高亮。
不过对于新手,更简单的方法是:在引导层中创建一个半透明圆角矩形,手动调整位置和大小,让它覆盖住目标物体屏幕坐标对应的区域。缺点是不够精确,但开发效率高。
如果你希望高亮跟着物体移动,可以在_process中不断更新高亮框的position和size,并且根据目标物体与摄像机的距离估算屏幕区域的缩放比例。这部分涉及数学计算,可以放到后续进阶教程中详述。
6. 全局数据与引导跳过逻辑
6.1 用自动加载保存引导状态
一般情况下,玩家只需要看一遍新手引导。下次进入游戏时,应该跳过引导,直接进入可自由操作状态。
Godot 提供了“自动加载”(Autoload)机制,可以把一个脚本作为全局单例,在整个游戏生命周期内保持存在。这里创建一个game_state.gd:
extends Node var tutorial_seen: bool = false在项目设置中将该脚本添加到 Autoload:
项目设置 -> 全局 -> Autoload -> 添加 路径: res://autoload/game_state.gd 名称: GameState这样在所有脚本中都可以通过GameState.tutorial_seen访问这个单例。
修改game.gd:
func _ready() -> void: if GameState.tutorial_seen: # 已经看过引导,直接允许玩家控制 player_control_enabled = true $TutorialLayer.hide() else: player_control_enabled = false $TutorialLayer.tutorial_finished.connect(_on_tutorial_finished) func _on_tutorial_finished() -> void: player_control_enabled = true GameState.tutorial_seen = true6.2 保存引导状态到磁盘
如果你希望玩家下次打开游戏时仍然记得已经看过了引导,需要把数据写入配置文件。Godot 可以用ConfigFile类:
func save_tutorial_seen() -> void: var config := ConfigFile.new() config.set_value("Player", "tutorial_seen", GameState.tutorial_seen) config.save("user://settings.cfg") func load_tutorial_seen() -> void: var config := ConfigFile.new() var err := config.load("user://settings.cfg") if err == OK: GameState.tutorial_seen = config.get_value("Player", "tutorial_seen", false)user://是 Godot 为每个项目分配的独立用户目录,适合存放存档和设置。
7. 运行验证与效果
7.1 运行主菜单
设置主场景为scenes/main_menu.tscn,点击运行。
预期效果:
- 窗口中心显示标题和三个按钮。
- 鼠标悬停在按钮上时按钮有高亮反馈。
- 点击“开始游戏”后切换到
game.tscn。 - 点击“退出游戏”后窗口关闭。
7.2 验证引导流程
在game.tscn中放置一个TutorialLayer实例,并确保TutorialLayer在场景树中处于最后位置。
运行主菜单并点击开始游戏,进入 3D 场景后:
- 看到引导对话框。
- 点击“下一步”依次切换文本。
- 点击“跳过”后对话框隐藏,玩家可以移动。
- 退出游戏并再次运行,如果已经实现了保存功能,引导不会再次弹出。
7.3 在编辑器中快速测试引导层
如果你不想每次从主菜单进入游戏测试,可以把game.tscn暂时设置为主场景,并直接在编辑器里运行。
此时如果出现相机或者角色相关报错,先检查是否缺少必要的节点。建议在开发初期的测试场景中只放一个地面和一个TutorialLayer,验证引导逻辑独立于游戏逻辑。
8. 常见问题与排查清单
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 点击按钮没有任何反应 | 按钮上方有其他透明 Control 节点遮挡 | 检查场景树顺序,调整 Z 轴或确保全屏遮挡层隐藏 |
| 鼠标点击穿透到 3D 场景 | 引导层没有设置全屏遮挡层 | 在引导层中添加 Control 并设置mouse_filter = STOP |
| 中文文字显示为方块 | 项目默认字体不支持中文 | 导入中文字体文件,并设置主题默认字体 |
| 切换场景后黑屏 | 目标场景没有摄像机 | 在game.tscn中添加 Camera3D 节点 |
| 玩家角色还能移动,引导没有生效 | 没有禁用角色输入 | 在游戏脚本中设置player_control_enabled = false |
| 引导层无法连接信号 | 节点路径写错 | 使用%唯一名称或从场景树中拖拽节点到脚本变量 |
Godot 3 中change_scene_to_file报错 | 版本 API 不同 | Godot 3 使用change_scene_to() |
排查信号问题时,可以在_ready()中打印相关信息:
print(start_button.get_signal_connection_list("pressed"))这样可以确认信号是否成功连接。
9. 最佳实践与工程建议
9.1 UI 节点命名与组织
- 统一使用
Button、Label、Panel等有意义的名称,不要使用Node2这类默认命名。 - 使用
%UniqueName标记关键节点,在脚本中用$%StartButton引用,可以防止父级结构调整后路径失效。 - 把 UI 布局与逻辑分离,样式尽量通过主题(Theme)设置,不用每个按钮单独调样式。
9.2 引导模块化设计
- 把引导层做成独立场景,通过
@export配置步骤文本。 - 引导层不直接依赖玩家或怪物节点的类型,而是通过信号通知外部。
- 对于复杂引导,可以把步骤数据做成 JSON 或资源文件,方便策划调整。
9.3 场景切换注意事项
- 不要在按钮信号回调中执行耗时操作,避免界面卡死。
- 如果场景加载较慢,使用
ResourceLoader.load_threaded_request()配合加载进度条。 - 背景音乐放在 Autoload 中,场景切换后音乐不会中断。
9.4 输入与鼠标状态管理
- 进入 3D 游戏时通常要捕获鼠标:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED。 - 打开暂停菜单或引导菜单时,要释放鼠标。
- 切回主菜单后,要重新设置鼠标模式为可见。
9.5 安全的场景入口
- 如果游戏包含存档系统,在主菜单要区分“开始游戏”和“继续游戏”。
- 点击“继续游戏”时先检查存档文件是否存在,避免加载空数据。
- 对存档文件进行备份和校验,防止损坏导致游戏无法启动。
10. 总结与下一步进阶方向
本文完成了 Godot 3D 项目中两个不可或缺的模块:游戏主页和游戏引导。通过场景搭建、信号连接、Autoload 全局数据以及简单的 3D 坐标转屏幕坐标原理,你可以为自己的游戏加上一个像样的开场流程。
接下来可以继续扩展的方向:
- 用 Tween 动画做标题和按钮的淡入效果。
- 通过 JSON 文件配置多关卡引导步骤。
- 实现引导高亮框自动跟随 3D 物体。
- 接入手柄输入与 UI 焦点导航,方便主机平台。
- 把引导系统与任务系统打通,让任务目标动态显示在 UI 上。
实际做项目时,建议先把主菜单和引导做成模板,这样后续每个新场景都能快速复用。欢迎收藏本文,对照代码动手实现一遍,遇到报错可以按上面的排查清单检查。实践永远是最好的学习方式。