用Godot写一款扫雷游戏,是我最近在带新同事入门时顺手做的一个练手项目。扫雷的规则足够简单,玩法老少皆知,但做成一个完整的游戏又需要涉及界面搭建、网格布局、事件输入、信号通信、数据结构和算法判断,几乎能覆盖Godot 2D游戏开发的常用基础技能。今天的记录是系列第一篇,只讲基础场景搭建,也就是把整个游戏的“壳”先立起来,把界面结构、节点体系、资源规划、全局设置都定好,后续再往里面填布雷、翻开、递归展开这些核心逻辑就会顺很多。适合刚开始接触Godot,想找一个完整小项目练手的朋友参考,也适合那些已经做过几个Demo但一直没正经做完一个游戏的人——这篇会告诉你,动手写逻辑之前,场景骨架到底应该怎么搭才不返工。
1. 项目整体设计与前期准备
1.1 扫雷这个项目到底在练什么
很多人刚上手Godot时会去看各种演示项目,角色移动、平台跳跃、弹幕射击,看起来热闹,但对新手来说其实不太友好。因为这类游戏的核心逻辑混在一起,物理系统、动画状态机、碰撞检测全都要懂,随便一个环节出问题都很难排查。而扫雷不一样,它本质上是一个二维数组的逻辑游戏,没有刚体,没有物理碰撞,没有复杂的动画和状态机,核心玩法就是点击和标记,但恰恰因为逻辑纯粹,它反而能让你把Godot的UI系统、节点树组织、信号通信、自定义资源和场景实例化这些真正基础的东西练扎实。
另一方面,扫雷在视觉表现上又有天然的“网格感”,而且需要大量的动态生成内容——格子数量随难度变化从几十到几百不等,不可能在编辑器里手摆。这意味着你必然要接触“代码实例化场景”和“动态填充容器”这两个高频技能。很多新手做项目时都是拖一堆节点,写完之后场景文件膨胀得没法看,但扫雷项目会逼着你从第一天就考虑场景复用和结构分层。
所以这篇文章虽然只讲场景搭建,但背后我真正在准备的是整个项目的架构地基。地基没打好,后面写布雷算法、递归展开、计时器逻辑时,代码会越写越乱;但要是结构规划到位,后面几篇的逻辑实现基本就是往搭好的架子上一块块填砖。
1.2 项目创建与全局设置
我用的是Godot 4.x版本,相比3.x在UI系统上改动很大,相关API也基本统一到了4.x的写法,新建项目时建议直接选最新的稳定版。打开项目管理器,新建项目,项目名称我就叫Minesweeper,渲染器选择上,如果你只做PC导出,选Forward+没问题,但考虑到后续可能会打包Web版本给朋友在线玩,我建议直接用Compatibility渲染器,兼容性最好,而且扫雷这种2D UI项目对渲染性能要求很低,Compatibility完全够用。
创建完项目之后,第一件事就是设置分辨率。项目设置里的Display选项,窗口宽度设1280,高度设720。注意这里有个细节:拉伸模式建议改成canvas_items,缩放方式保持keep aspect即可。这个组合的意思是,游戏渲染画布按设计分辨率绘制,窗口如果被拉大,UI会等比放大,窗口被压扁时则自动留黑边,不会出现UI被拉伸变形的情况。扫雷是纯UI交互游戏,如果分辨率适配没设好,后续在不同显示器上打开可能会出现格子错位、点击偏移这类诡异问题。
另外设置里记得把窗口最小尺寸设一下,比如最小宽960、高540,防止玩家把窗口拖到极小导致无法操作。运行窗口和游戏内容的比例保持一致,对后续使用锚点布局也很有帮助。
1.3 目录结构规划:别等代码多了再后悔
我在帮新人看项目代码时,见过太多把所有脚本和场景堆在根目录下的情况。项目刚起步时无所谓,等资源一多,你连哪个文件对应哪个功能都分不清,更别提出Bug时快速定位。扫雷项目规模不大,但既然要写成一个系列,目录规划应该在第一天就做好。
我的习惯是分四类:scenes放场景文件,scripts放脚本,assets按资源类型再拆子目录(fonts、themes、textures、audio),tests放自动化测试。具体到扫雷项目,目录结构大概是这样的:
text复制res://
├── scenes/
│ ├── game/
│ │ ├── Game.tscn
│ │ └── Game.gd
│ ├── ui/
│ │ ├── TopBar.tscn
│ │ └── TopBar.gd
│ └── cell/
│ ├── Cell.tscn
│ └── Cell.gd
├── scripts/
│ ├── data/
│ │ ├── GameConfig.gd
│ │ └── Difficulty.gd
│ └── utils/
├── assets/
│ ├── fonts/
│ ├── themes/
│ └── textures/
└── tests/
按场景模块拆目录而不是按文件类型拆,是我用下来比较顺手的方式。这样Game.tscn和Game.gd永远放在一起,改界面时不用在几个目录之间来回跳。assets下面按类型拆子目录,因为同一类资源通常会被多个场景共用,合并管理效率更高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 场景树设计与节点结构
2.1 根节点选型:为什么是Control而不是Node2D
很多刚开始用Godot的人会有一个思维惯性,凡是游戏都先建一个Node2D节点当根,因为之前做角色移动时教程都这么教的。但扫雷这个项目不同,它没有基于坐标系的游戏对象,所有东西都是UI元素,所以根节点应该选Control体系里的节点,我最终用的是Control。
这里的关键区别是:Control节点自带锚点、边距、对齐布局,以及输入事件的处理能力,而Node2D没有这些属性。如果你用Node2D做根节点,那所有UI元素都要手动计算坐标,而且窗口缩放时不会自动适配。用Control做根节点,子节点可以相对父级自适应,会省掉大量坐标计算工作。
根节点定下来之后,我把整个游戏的界面划分为三个区域:顶部状态栏、中间雷区、底部操作栏。这样划分的依据是扫雷游戏的基本交互流程——玩家需要看到剩余雷数、需要操作格子、需要选择难度和重开游戏。三个区域各自独立,互不干扰,正好可以对应成三个独立的子场景,方便后续维护。
2.2 三个区域的界面拆分
顶部状态栏我用的是一个PanelContainer,里面横向排开三个元素:左侧显示剩余地雷数量,中间是重新开始按钮,右侧显示计时。这个排布和Windows经典扫雷是一致的,玩家不需要学习成本。中间雷区是整个游戏的核心区域,我留了一个普通的Control节点作为容器,后续格子会在代码里动态生成并填充进去。底部操作栏同样用PanelContainer,放难度选择按钮和一段提示文本。
在这个架构里,Game.tscn只负责组装,不在编辑器里写死任何雷区的格子内容。所有格子的生成逻辑都留在后面的动态创建部分,这样场景文件和运行时状态彻底解耦,不会因为要调整难度参数而反复修改场景。
2.3 节点命名与信号预留
节点命名这件事,看起来简单,但在团队协作或者隔一两个月回来看自己代码时,很容易成为痛点。我要求自己所有节点都使用明确的含义命名,而不是默认的Label、Button这种泛化名称。比如顶部状态栏里的三个关键节点,我分别命名为MineCountLabel、RestartButton、TimerLabel,Minefield作为雷区容器,难度按钮就命名为BeginnerButton、IntermediateButton、ExpertButton。你看到名字就知道这个节点负责什么,不需要点开检查器去看它的类型。
还有一个提前做好的规划:信号预留。比如RestartButton虽然现在还没有实际逻辑,但我在场景里已经把它的pressed信号连接到Game.gd里的_on_restart_pressed方法。提前连好空方法的好处是,后续实现逻辑时不用再回场景里找节点连接信号,只需要往方法里填代码。难度按钮的组关系也在场景搭建阶段统一设置好,三个按钮共用一个ButtonGroup,保证同时只能选中一个难度。这不是什么复杂技巧,但能说明一个问题:基础场景搭建不只是拖控件,还要把交互结构、数据流方向都想清楚,后面才不乱。
3. 核心实操:UI控件的搭建与主题定制
3.1 顶部状态栏:两行字体大小搞定信息展示
顶部状态栏如果直接用Label显示数字,默认字体偏小,玩家要歪着头看。我的做法是给MineCountLabel和TimerLabel设置一个专门的字体大小,直接点击节点,在检查器的Theme Override Font Sizes里把Font Size调到28。它们共同的特点是信息展示型文本,需要一眼看到,所以统一使用更大的字体。RestartButton则使用一个近似方形的图标按钮,文本用Emoji符号或者简单方块都可以。注意不要用表情符号作为核心交互标识,不同系统渲染差异很大,最好用普通的文字符号或者自己画一张小图。
顶部状态栏的摆放直接用HBoxContainer横向布局,Container会自动处理子节点的间距和对齐,这比手动挪节点位置要稳妥得多。我在HBoxContainer里给三个子节点设置了不同的Size Flags,雷数Label和计时Label向两侧拉伸,重新开始按钮在中间Fixed,这样无论窗口怎么缩放,中间按钮都能保持在视觉中心。
3.2 雷区容器:GridContainer还是手动布局
雷区是整个场景搭建里最有技术含量的一部分。一个扫雷棋盘是行列都很规整的网格,最直观的方案是用GridContainer,设置列数后,子节点会自动按网格排列。但GridContainer有个特点,它的大小由所有子节点总和决定,如果子节点尺寸不统一或者容器本身有固定尺寸,很容易出现溢出或者内容被裁剪的问题。
另一种方案是手动计算每个格子的坐标,在代码里用set_position控制。这种方案最灵活,可以随意控制格子的间距、偏移和尺寸,而且后面做动画效果时会顺手很多。但缺点是代码量翻倍,每次窗口尺寸调整都要重新计算整个棋盘的偏移中心。
我在这个项目里先用的是GridContainer,配合一个CenterContainer做居中包装,这样雷区无论有多少行多少列都会自动居中显示。后续如果需要做格子翻转动画,可以再把手动布局版本当作重构选项——毕竟这只是一个系列里的第一篇,不必把所有优化空间都占满。
3.3 借Button实现格子:状态与样式说明
格子本身我并没有新建一个Node2D类型的场景,而是直接用Button作为格子场景根节点。Button天然带按下、悬停、禁用三种状态,配合不同的主题样式可以快速表现:
- 默认状态:一个凸起的小方块,灰色底、深色边框
- 按下状态:凹下去的效果,提示玩家这个格子已经被翻开
- 禁用状态:翻开后不能重复点击
- 悬停状态:鼠标移上去时稍微亮一点
为什么不用TextureRect加自定义脚本?原因很简单,Button已经封装了鼠标事件、焦点处理、按下反馈这些UI交互最底层的逻辑,自己实现这些功能至少要写上百行代码,而且很容易写出边角Bug。扫雷的格子本质上就是一个可点击的按钮,语义完全匹配。
主题样式上,我没有在每个按钮上单独调StyleBox,而是在项目里新建了一个Theme资源,统一设置Button的各个状态样式。样式都是纯色StyleBoxFlat,边框宽度2像素,外凸时用浅色顶边加深色底边,内凹时反过来,这样格子会有非常精致的立体感。Theme资源做好之后,放到项目的assets/themes目录下,在Game根节点的检查器里指定为Theme属性,整个项目里的所有Button都会自动套用这套样式,不需要手动逐个配置,后面维护主题也只需要改一个文件。
3.4 全局字体与中文显示的关键
Godot 4的默认字体是不支持中文的,如果你直接往Label里敲“剩余雷数”,运行起来会看到一个个方框,这是几乎每个新手都会踩的坑。网上搜索“godot 使用字体 绘制”能看到大量相关求助帖,但很多回答讲得比较绕。
我的解决办法是:先在系统里找一款开源的中文字体,比如思源黑体或者Noto Sans SC,把它们放到assets/fonts目录下。然后在项目根节点上创建一个Theme资源,在Theme的Font属性里设置这个中文字体文件。关键一步是,Theme资源里的默认字体设置要放在字体列表的Default字体槽位。这样设置之后,所有没有单独指定字体的Label和Button,都会自动用这个中文字体渲染,而不是一个个节点去设置。
针对TimerLabel这种需要等宽数字的场景,我额外单独指定了一个等宽字体,因为扫雷的计时数字跳动时,如果每个数字宽度不一样,整个标签会左右晃动,非常影响体验。像数字0-9这种等宽字体,我直接用默认字体就能解决,Godot里设置Label的Font Context可以启用等宽数字特性,这个细节如果不是对用户体验敏感,很可能会被忽略。
4. 动态生成场景的准备工作
4.1 格子场景的独立封装
网格布局用的控件和某个格子自身的交互逻辑是两回事。我的做法是先单独做一个Cell.tscn场景,根节点是一个Button,它的脚本预留几个方法:set_mine、set_number、reveal、toggle_flag、get_neighbors。这些方法现在的实现都是空壳,先在Cell.gd里定义好方法签名,后面再填充具体逻辑。然后给Cell场景设置一个属性,比如export var column,用来标记自己在棋盘中的行列位置,后续网格布局就可以通过它来回查邻居。
场景封装的好处是明显的:格子的样式、输入处理和数据结构都在一个场景文件里集中管理,后面不管雷区是Game.tscn直接生成还是单独做一个Minefield.tscn来管理,都只需要实例化Cell节点即可,不用修改控制器和UI层代码。为了达到场景复用,我还把Cell场景的尺寸设为36×36,加上2像素的边框和4像素的间距,这样每个格子实际占地40像素左右,视觉上不至于太挤。
4.2 难度参数的统一定义
扫雷的经典难度有三种:初级9×9棋盘10颗雷,中级16×16棋盘40颗雷,高级16×30棋盘99颗雷。在项目正式写逻辑之前,我先在scripts/data/Difficulty.gd里用枚举定义好三档难度,并把每档的行列数和雷数作为常量保存下来。
gdscript复制enum Difficulty { BEGINNER, INTERMEDIATE, EXPERT }
const DIFF_CONFIG = {
Difficulty.BEGINNER: { "rows": 9, "cols": 9, "mines": 10 },
Difficulty.INTERMEDIATE: { "rows": 16, "cols": 16, "mines": 40 },
Difficulty.EXPERT: { "rows": 16, "cols": 30, "mines": 99 },
}
这一步放在基础场景搭建阶段是有意为之:当你在测试不同难度时,雷区的行列数会直接影响网格容器需要设置多少列,如果把这些参数写死在场景里,切换难度时就要重开场景;定义成统一配置后,逻辑层和UI层读到的数据永远是一致的,以后加自定义难度,也只需要往这个字典里加一项,不会有别的代码需要改。
4.3 锚点、拉伸模式与窗口适配
之前提到要把拉伸模式设成canvas_items,但UI层的适配工作不止这一步。在Game场景里,顶部状态栏和底部操作栏我都用PanelContainer并设置了完整的水平边距,而雷区容器则使用AspectRatioContainer或者CenterContainer包裹。AspectRatioContainer能保证内容在窗口缩放时始终维持设计时的宽高比,中间留白会自动分配到边缘,不会压缩核心棋盘区域。
还有一个小技巧:对动态生成的格子,使用GridContainer时,需要在代码里同步设置列数。扫雷的棋盘是长方形的,列数是固定的,改变的是总行数。所以在代码里创建完格子后,要先把GridContainer的columns设成当前难度的列数,再把所有Cell实例添加进去。顺序不能反,先设置列数再添加子节点,否则容器会在添加第一个子节点时按默认列数排列,出现不可预期的换行。
5. 常见问题与排查技巧实录
5.1 中文乱码方框
现象:运行游戏后,Label和Button上的中文全部显示成方框。原因几乎一定是默认字体不支持中文字形。解决方法是按3.4节的流程,在Theme资源里配置中文字体。另外一个隐蔽的坑是:如果你直接修改了项目设置里的字体默认值,确实会生效,但对部分控件比如LineEdit、TextEdit不一定全局生效,最好还是在Theme资源的Default字体里设置,因为Theme的优先级高于项目设置。
5.2 网格溢出、居中偏移
现象:雷区格子数量较多时,GridContainer内容超出屏幕范围,或者不居中。排查步骤:先确认GridContainer的父节点是CenterContainer还是普通Control,如果是普通Control,GridContainer的Size Flags有没有设置对。GridContainer自身大小由内容决定,它默认会以左上角为原点展开,如果你希望它居中,必须把它放到一个会计算布局的容器里。我最终用的是CenterContainer嵌套GridContainer,容器设置为全屏锚点,格子在内部居中对齐,窗口怎么缩放都没问题。
5.3 按钮主题连点误触
现象:格子按钮在快速点击时,有时候按一下触发了两次翻开。这个在扫雷里很常见,尤其是玩家快速双击格子时,第二次点击如果已经处于翻开状态,就会造成误触。解决方案是在后续逻辑里,翻开操作完成后立即把按钮设为disabled,同时在_input事件里过滤掉已经处理过的事件。现在场景搭建阶段,我先把Button的Toggle Mode关掉,确保按下时不会因为按钮被保持在按下状态而产生额外的反色效果。这个细节我会在下一篇文章里正式处理,但场景层提前关掉这个模式能避免很多视觉干扰。
5.4 场景结构冲突排查清单
如果运行后场景白屏或者节点错位,我按下面的顺序排查:
- 检查根节点类型,是否使用了Control体系而不是Node2D
- 检查锚点类型,所有PanelContainer是否设置了full rect或者正确的锚点
- 检查Container的排列方向——HBoxContainer是横向,VBoxContainer是纵向,搞混了很常见
- 检查Theme主题是否为所有Button统一设置了Font和StyleBox,如果某个子节点单独设置了Override但内容为空,会产生意想不到的冲突
- 检查动态生成部分是否在场景实例化完成后才执行,避免在_ready之前操作尚未入树的子节点
写在后面
从零开始做扫雷的系列记录,第一篇的内容其实比想象中要多。好在我一直有个习惯:动手写第一行游戏逻辑之前,先把场景当成产品来设计,把界面拆分成模块,把命名和数据定义统一起来。基础场景搭建虽然看起来没有激动人心的效果,但它是后续所有功能的地基,地基歪了,后面每一层都会跟着歪。
制作过程中我踩过的最典型的坑就是那个中文字体问题,第一次跑的时候满屏方块差点让我怀疑是不是Godot坏了,后来才发现只是默认字体不支持中文。还有GridContainer的列数设置顺序,少写一行代码,网格就全部乱套。这些经验在官方文档里其实都有提到,但字里行间不够醒目,只有自己炸过一遍才记得牢。
下一篇我会正式进入核心逻辑:地图生成、地雷布局、数字计算、左键翻开、右键插旗、递归展开空白区域。这些内容全部会建立在今天搭好的场景骨架上,到时候你会发现,前期的这些结构性工作会让逻辑层的代码干净很多。
