Godot做扫雷游戏,这个念头在我心里转了挺久。扫雷这东西规则足够简单——点格子、看数字、排除地雷,但麻雀虽小五脏俱全。网格布局、随机生成、事件分发、UI交互、状态管理,这些游戏开发里的常见环节一个都跑不掉,拿它来练手Godot的场景组织思路,再合适不过了。
这个系列我会分几篇把整个项目拆开来讲,第一篇先聚焦在基础场景搭建这件事上。说得直白点,就是在Godot编辑器里把项目骨架立起来:窗口多大、界面长什么样、格子按钮怎么做、场景之间怎么切换。这些"静态"的部分一旦搭好,后面所有逻辑代码才有地方挂。
如果你之前没用过Godot,或者刚接触场景(Scene)和节点(Node)这套概念,这篇文章应该能帮你少走不少弯路。我尽量把每一步的"为什么"也讲清楚,不只是把操作步骤丢给你。
1. 项目整体规划与场景拆分思路
1.1 核心玩法拆解:先搞清楚要做哪些系统
很多人做小游戏有个通病,一上来就急着写代码,结果写了一半发现结构乱成一锅粥。我的习惯是先花半小时把玩法拆解清楚,再动手搭场景。
扫雷的核心规则其实只有几条:
- 网格里随机埋了一定数量的地雷,玩家通过点击格子翻开区域。
- 点击到没有雷的格子,会显示一个数字,代表周围八格有多少颗雷。
- 如果数字是0,会自动向外扩展翻开一片空白区域,这是扫雷最核心的"连锁反应"。
- 玩家可以右键标记格子,标记为疑似地雷或确定地雷。
- 翻开所有非雷格子或者标对所有雷,就算胜利;点中雷则游戏结束。
这套规则拆成系统来看,需要的东西并不少:网格生成模块、地雷随机分布算法、数字计算逻辑、格子点击响应、雷区标记、胜负判定、计时器,还有UI面板上的剩余雷数和重置按钮。
但注意,这是系列第一篇,我刻意只做"基础场景搭建"。也就是说,这阶段的目标不是让游戏能玩,而是把场景结构和节点层级建好,让游戏窗口能正常跑起来,按钮长在该在的位置,格子能显示出来。具体的地雷算法和交互逻辑,留到后面几篇再填。
1.2 为什么选Godot做这个项目
选引擎这件事,其实没有绝对的对错,关键是看项目类型和个人习惯。扫雷是典型的2D界面密集型游戏,用Godot来做有几个非常舒服的点:
第一,Godot的场景继承与实例化机制特别适合扫雷这种"一个格子反复出现很多次"的项目。你只需要做一个格子场景,然后在代码里复制几十上百个实例就行,不需要在编辑器里手动排列。
第二,Godot 4.x的信号(Signal)系统在处理UI点击事件时非常直观。每个格子按钮被点击,通过信号发给游戏主控节点,天然就是观察者模式,不需要额外写事件管理器。
第三,引擎本身轻量,编辑器打开速度快,迭代调试的体验很顺畅。对于扫雷这种体量的小游戏,开着Godot写代码基本感觉不到卡顿,这比某些重型引擎(我无意拉踩,但写过的人应该懂)要舒服太多。
版本方面,我建议直接用Godot 4.x(撰写本文时最新稳定版为4.3)。相比3.x,4.x在2D渲染、UI系统、GDScript语法上都有明显改进。如果你还在用3.x,建议迁移。这个系列的所有内容都基于4.x,一些API的细微差别我会在第4章专门提。
1.3 场景与节点:Godot的组织逻辑要先想明白
Godot和Unity、Unreal最大的不同,是它的"场景即节点树"这套设计。一个场景(Scene)本质是一棵打好的节点(Node)树。你可以把节点理解成游戏里一个最小的功能单元:一个按钮是节点,一个精灵图是节点,一个音效播放器也是节点。把多个节点按父子层级拼起来,就组成了一个场景。
扫雷项目里,我规划了三层结构:
- 最高层是主游戏场景(Main),负责管理整个游戏的流程和子模块。
- 中间层是UI界面场景,包括顶部信息栏、网格容器、底部操作区。
- 最底层是单个格子(CellButton),这是被大量复用的单元,相当于工厂里的标准零件。
为什么不用一个超大场景把所有节点全塞进去?因为可维护性太差了。你想想,如果100个格子全部平铺在同一个场景文件里,光在编辑器里找某个节点就要找半天。用一个格子按钮场景,代码里动态实例化,干干净净。
动态创建格子还有一个好处:扫雷的难度是可以选的。初级9×9、中级16×16、高级30×16,格子数量不一样,动态生成就是根据参数循环生成,而不是为每个难度准备一个静态场景。
这个思路理清了,下面可以开始动手了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础场景搭建的完整实操
2.1 创建项目与初始设置
打开Godot项目管理器,点击"新建"按钮,填上项目名称(我用的是MinesweeperGodot),选择存放路径,渲染器我直接用的默认的Forward+。有朋友可能会问,2D游戏是不是用Compatibility兼容模式更省资源?我的看法是:扫雷这个项目里几乎没有复杂的2D渲染压力,默认的Forward+在桌面平台跑起来毫无问题,没必要额外折腾。
项目创建好之后,先进"项目设置"做一些必要的调整:
- 基础窗口尺寸:我设置成1280×720。这个尺寸下,无论是9×9的初级网格还是30×16的高级网格,都能有充足的空间展示。
- 窗口拉伸模式:在"显示→窗口→拉伸"里,把拉伸模式设为
canvas_items,拉伸倍率设为expand。这是2D游戏UI适配的常用组合,意思是画面内容按画布尺寸缩放,同时允许不同分辨率下扩大可视区域。 - 主场景绑定:虽然现在还没有场景文件,但先记着这个位置。项目设置里的"运行→主场景"可以指定游戏启动后自动加载哪个场景。后面我们做好Main.tscn后,就在这里把它绑定上。
这些设置不复杂,但每一项都影响后面的体验。窗口尺寸如果选得太小,高级难度的30列网格就会挤成一片;拉伸模式如果不选对,窗口大小变化时UI就会错位或者模糊。
2.2 搭建游戏主场景(Main.tscn)
在文件系统面板里右键,创建一个新场景,根节点我选择的是Control类型。
这里多说一句为什么不用Node2D。扫雷本质是一个UI互动游戏,几乎所有元素都是控件(按钮、标签、面板),用Control作为根节点,可以直接利用锚点(Anchor)系统来做自动布局。Node2D更适合做有坐标运动的对象,比如角色、子弹,扫雷这个项目里基本用不到。
我习惯把根节点命名为Main,然后给它挂一个GDScript脚本(脚本名我自然也叫Main.gd)。虽然这一篇的代码不多,但脚本文件最好现在就建好,后面写逻辑就不用再切换来切换去。
接着在Main节点下,我规划了这样几个子节点:
- Background(ColorRect):一个覆盖全屏的背景色块,用来打底。
- UILayer(Control):承载所有UI控件,设置成全屏矩形锚点,这样无论窗口多少尺寸,它都会自动铺满。
- TopBar(PanelContainer):顶部信息栏,放剩余雷数、计时器、重置按钮。
- BoardContainer(Control):网格区域容器,后面生成的格子按钮会挂在这里。
- BottomBar(PanelContainer,可选):底部提示文本区域,我预留来放操作提示。
创建子节点的方式很简单:选中Main节点,点击编辑器顶部的"+"号,添加子节点。然后把每个节点的名称改掉、在检查器面板里调整属性。
这里有一个新手很容易忽略的点:锚点预设。选中UILayer,在顶部工具栏的锚点预设里选择"完整矩形",它就会自动把四条边钉在父节点的四边上,配合拉伸模式实现自适应。如果你不做这一步,换个电脑分辨率UI就乱跑了。
2.3 创建格子单元格场景(CellButton.tscn)
接下来做这个项目最重要的复用组件——单个格子。
我单独创建了一个新场景,根节点用Button,命名为CellButton。为什么直接用Button而不是TextureButton或自定义控件?因为Button自带按下、悬停、禁用等状态,还有内置的样式盒主题,做扫雷格子够用了。
这个Button的初始大小我设为48×48像素。虽然最终的实际格子大小会在代码里动态计算,但编辑器中先设一个基础尺寸方便预览。
给根节点挂上脚本CellButton.gd,脚本内容暂时很简单:
gdscript复制extends Button
signal cell_pressed(row: int, col: int)
signal cell_right_pressed(row: int, col: int)
var row: int = 0
var col: int = 0
func setup(r: int, c: int) -> void:
row = r
col = c
text = ""
这里定义了row和col两个变量,以及两个信号。后面逻辑完善后,格子的点击事件会通过信号发给上层,而不是按钮自己处理游戏规则。这是标准的"子节点只负责表现,父节点负责逻辑"的做法。
场景文件保存之后,在Main场景里不需要拖入这个场景(因为要在代码里动态生成),但可以先拖一个进去预览一下效果,确认大小、样式没问题再删掉。我建议每个新手都这样试一下,在编辑器里看到实际运行效果,能避免很多"代码写完发现样式不对"的问题。
2.4 顶部信息栏和网格区域布局
回到Main场景,把TopBar的布局搭好。
TopBar我用的PanelContainer,它会自动根据子内容调整大小,方便做背景色。在它下面添加一个HBoxContainer,水平排列三个元素:
- 一个
Label用来显示剩余地雷数。 - 一个
Button作为重置/重新开始按钮。 - 一个
Label用来显示计时。
这里有个小技巧:在HBoxContainer里,为了让三个元素均匀分布,可以在第一个Label和第二个Button之间、第二个Button和第三个Label之间各加一个Control节点,并把它的size_flags_horizontal设为3(Expand)。这样两边的Label和中部的Button就会保持一定距离,更接近正经扫雷游戏的界面布局。
网格区域BoardContainer的处理相对简单:它只是一个空的Control节点,锚点设置为"顶部宽",把TopBar下面的空间占满。后面代码里就是通过这个节点来添加格子实例。
code复制Main (Control)
├── Background (ColorRect)
├── UILayer (Control)
│ ├── TopBar (PanelContainer)
│ │ └── HBoxContainer
│ │ ├── MineCountLabel (Label)
│ │ ├── Spacer (Control)
│ │ ├── ResetButton (Button)
│ │ ├── Spacer (Control)
│ │ └── TimerLabel (Label)
│ ├── BoardContainer (Control)
│ └── BottomBar (PanelContainer)
这个节点树就是整个基础场景的骨架。我建议你搭完之后先在编辑器里运行一下,虽然现在界面是空的,但窗口能正常打开、布局不会报错,说明这层地基是稳的。
3. 关键系统设计思路与参数设定
3.1 网格数据模型与关卡参数设计
场景搭建的下一步,是确定格子怎么排列。我在代码里预先定义了一组扫雷常见的难度参数:
| 难度 | 宽(列) | 高(行) | 地雷数 | 格子大小建议 |
|---|---|---|---|---|
| 初级 | 9 | 9 | 10 | 64×64 |
| 中级 | 16 | 16 | 40 | 40×40 |
| 高级 | 30 | 16 | 99 | 36×36 |
为什么这样设定?格子大小的计算逻辑其实很简单,我们需要在1280×720的窗口里,给顶栏留出大约80像素,给底栏留出40像素,剩下约600像素的高度给网格。初级难度9行,600÷9≈66,取64。中级16行,600÷16≈37,取36或40都行。高级16行也是类似。
计算格子大小这一步,我建议直接在代码里做,而不是在编辑器里手填:
gdscript复制var viewport_width = 1280
var board_height = 600 # 减去顶栏和边距后的可用高度
var cell_size = int(min(viewport_width / cols, board_height / rows))
用min取宽高两个方向中较小的那个值,保证格子是正方形,并且整个网格不会超出可用区域。这是一个很基础但很关键的布局参数,很多跟着教程做的人在这里偷懒,直接写死格子大小,结果换一下难度就穿帮了。
数据模型方面,虽然这一篇不展开讲具体算法,但我在Main.gd里先把变量占位定义好:
gdscript复制var rows: int = 9
var cols: int = 9
var mine_count: int = 10
var grid: Array = [] # 二维数组,0=未翻开,1-8=数字,-1=地雷
var cell_scene: PackedScene = preload("res://scenes/CellButton.tscn")
这里用PackedScene的preload把格子场景加载进来,是Godot里做动态实例化的标准写法。二维数组grid用来保存每个格子的实际数据,这个数组会在下一篇的地雷生成逻辑里用到。
3.2 场景与脚本的分工:每个节点只做好一件事
我看到很多Godot新手项目,最大的问题就是所有代码全堆在主场景的脚本里,哪怕是一个按钮的样式调整都要去翻一整篇几百行的代码。这其实是把Godot的场景优势给浪费了。
我的习惯是:每个场景节点只负责自己那一亩三分地。
- CellButton场景:只负责显示格子外观,把点击事件通过信号抛出去,它不关心自己是地雷还是数字。
- Main场景:负责生成网格、订阅格子的信号、更新UI显示。它是整个游戏的控制中心。
- (后续)GameManager单例:负责全局游戏状态(进行中/胜利/失败),以及难度参数的切换。
这样做的好处是,每个脚本只有几十行,出问题的时候定位很快。比如格子点击没有反应,先检查CellButton场景的信号有没有正确发出,再检查Main场景有没有正确连接,两个环节各自独立,排查范围瞬间缩小一半。
这个理念在Godot的官方文档里也有强调,叫做"组合优于继承"。用场景搭建的方式做游戏,本质就是在用组合的方式搭积木。前期多花一点时间把节点分工理清楚,后期填大量逻辑代码时会特别顺畅。
3.3 主菜单场景与游戏场景的切换流程
虽然这篇的主题是"基础场景搭建",但我觉得有必要提前把场景切换的骨架也搭好。扫雷游戏一般有一个主菜单场景(选择难度),点击"开始游戏"后进入游戏场景。
所以我除了Main.tscn,还建了一个Menu.tscn。里面放了几个简单按钮:初级、中级、高级。每个按钮绑定一个方法,跳转到游戏场景:
gdscript复制func _on_beginner_pressed() -> void:
GameManager.init_game(9, 9, 10)
get_tree().change_scene_to_file("res://scenes/Main.tscn")
这里我用了一个自动加载的单例(Autoload)GameManager来保存游戏参数。如果你还不熟悉Autoload,可以把它理解成一个全局的公共工具箱,任何场景都能访问它、往里面存数据。
创建Autoload的方法:在项目设置里找到"全局(Globals)→自动加载",添加GameManager.gd脚本。脚本内容大概是这样:
gdscript复制extends Node
var current_rows: int = 9
var current_cols: int = 9
var current_mines: int = 10
func init_game(r: int, c: int, m: int) -> void:
current_rows = r
current_cols = c
current_mines = m
这样做的好处是,不同场景之间传递数据的时候不用搞复杂的参数引用。主菜单设置好参数,然后切换场景,游戏场景的_ready()方法里读取这些参数,直接初始化网格。这套流程是Godot项目的通用做法,扫雷虽然小,但把场景切换的框架搭好,后面做其他项目也能直接用。
在Main.gd的_ready()里,暂时只需要做一件事:读取GameManager的参数,然后预留一个_build_board()方法(本篇可以先留空或只打印一行日志,具体生成算法下篇实现)。
gdscript复制func _ready() -> void:
rows = GameManager.current_rows
cols = GameManager.current_cols
mine_count = GameManager.current_mines
_build_board()
func _build_board() -> void:
print("开始生成网格:", rows, "x", cols, ",地雷数:", mine_count)
4. 踩坑记录与常见问题排查实录
4.1 节点路径引用报错:多用@onready和唯一名称
在Godot里,脚本中访问场景里的节点,最忌讳的做法是写死路径。比如这样:
gdscript复制var label = get_node("UILayer/TopBar/HBoxContainer/MineCountLabel")
一旦你把某个节点挪了个位置,或者在中间插入了一层容器,这个路径就断了,运行时报错"Node not found"。我在做这个项目时踩过好几次这个坑,后面学乖了,改用两种方式:
第一种是用@onready声明变量:
gdscript复制@onready var mine_count_label: Label = $UILayer/TopBar/HBoxContainer/MineCountLabel
这样虽然还是路径引用,但@onready保证了节点树就绪后才赋值,而且路径集中在脚本顶部,修改起来方便。
第二种是给节点设置唯一名称。在检查器里右键节点,选择"访问→设为唯一名称",节点名会变成%MineCountLabel这种带百分号的形式。然后脚本里直接写$%MineCountLabel,这样即使节点在场景树中挪了位置,只要唯一名称不变,引用就不会断。这个功能在4.x版本里非常好用,我强烈推荐。
4.2 UI缩放适配问题:窗口变形后布局乱掉
项目刚开始的时候,我没给根节点设置好锚点,只把窗口大小固定成1280×720。结果用户一拉窗口边缘,界面就乱的没法看——顶栏不在顶部了,按钮间距也怪怪的。
解决方案分两步。第一步是在项目设置里设置好拉伸模式,我用的是canvas_items+expand,这样画面会按照初始设计分辨率等比缩放。第二步是给每个UI容器设置锚点:顶部栏用"顶部宽",网格区域用"全矩形"。这样任何宽度下,各区域都会自动重新撑开。
如果你希望游戏窗口固定大小、不允许缩放,也可以在项目设置里取消勾选"允许拉伸",但我在做扫雷时建议保持可缩放,毕竟玩家可能在不同的显示器上玩。
4.3 信号连接失败:老手也会翻车的调用陷阱
Godot 4.x里连接信号有两种方式。一种是在编辑器里选中节点,在右侧"节点"面板里双击信号,选择目标方法;另一种是在代码里用connect方法。两种方式都很常用,但有一个细节要注意:
如果信号是带参数的,比如前面CellButton里定义了cell_pressed(row, col),在连接时的回调方法必须接收同样数量的参数:
gdscript复制func _on_cell_pressed(row: int, col: int) -> void:
# 处理格子点击
pass
如果参数数量不匹配,Godot不会在编辑器时报错,而是运行时在控制台里打印一条错误信息然后什么都不干。第一次遇到这种情况容易一脸懵,排查了半天也不知道为什么按钮没反应。我的经验是,遇到信号不触发,先检查回调方法的参数数量,这是最高频的原因。
4.4 Godot版本差异:3.x项目迁移到4.x要注意的API变化
Godot 3.x到4.x有很多破坏性更新,如果你看的教程是老的,抄代码时要注意。我列几个这次项目中实际遇到的:
| 功能 | Godot 3.x写法 | Godot 4.x写法 |
|---|---|---|
| 切换场景 | change_scene() | change_scene_to_file() |
| 获取节点 | get_node("路径") | $路径 或 get_node("路径") |
| 信号连接 | connect("name", self, "method") | connect("name", Callable(self, "method")) |
| 渐入渐出 | fade_in() | create_tween() |
| 导入图片 | 默认方式 | 需要调整导入模式 |
如果你是从3.x迁移过来的,这几个点几乎每天都会踩到。尤其是change_scene这个改法,3.x的写法在4.x里直接报错,编辑器提示也不够明显,我当时困惑了好一阵。
4.5 常见问题速查表
为了方便排错,我把这一篇里可能遇到的问题整理成一个速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 运行后窗口黑屏 | 主场景未设置 | 项目设置→运行→主场景,选择Main.tscn |
| 节点报错找不到 | 节点路径写错 | 改用@onready或唯一名称% |
| 窗口缩放时UI错位 | 锚点没设置 | 选中节点,在工具栏设置锚点预设 |
| 信号连接后不触发 | 回调参数数量不匹配 | 检查回调方法参数个数与信号是否一致 |
| Button点击无视觉反馈 | 没有设置主题 | 创建Theme或使用内置样式覆盖Normal/Hover/Pressed状态 |
| 代码运行时报preload失败 | 场景文件路径错误 | 确认res://路径大小写、文件名一致 |
| 拉伸模糊 | 纹理过滤设置不当 | 对像素风格素材,把导入设置中Filter改为Nearest |
速查表可能不够全,但覆盖了我整个系列做下来实际遇到的绝大多数问题。后续如果遇到新的坑,我会在更新文章时补进去。
写在最后的经验之谈
基础场景搭建这件事,听起来好像只是准备工作,不写逻辑、没有算法,感觉没什么技术含量。但恰恰是这一步,决定了你整个项目后续的代码结构。我在做这个扫雷项目过程中最大的体会是:Godot的场景系统是一把双刃剑,用好了项目结构清清楚楚,用不好就是一堆节点堆成一坨,改一个功能动全身。
我的建议是,无论项目多小,都别跳过规划这一步。花几分钟把节点树画出来,想清楚哪个节点管什么,哪个信号该发到哪里,比你多写几百行代码都值。
这一篇先到这里,下篇我会重点写地雷生成与数字计算的实现,以及格子点击后的翻开逻辑。到时候你会发现,只要基础场景搭好了,填逻辑是一件非常顺滑的事。
