简介:面向Qt与C++初学者的一份简易贪吃蛇项目源码,适合通过小型图形游戏理解QWidget主窗口、QGraphicsView/QGraphicsScene场景绘制与事件驱动开发。压缩包共8个文件,约209KB,包含2个cpp与1个h源码、1个ui界面文件、1个pro工程文件,以及2张jpg预览图和1个user配置,便于直接导入Qt Creator查看和运行。已有264人学习下载,可作为课堂练习或课程设计参考。代码围绕Snake/SnakeBody、Food、Boundary等对象组织,覆盖蛇身移动、碰撞检测、吃食增长、键盘转向、分数计时与游戏结束处理,并体现信号槽关联界面更新与游戏逻辑,适合用来梳理Qt图形编程和C++面向对象封装的基本思路。
1. C++ 与 Qt 的贪吃蛇,为什么适合当作第一个能跑的项目
想上手 Qt 的人常卡在同一个地方:文档看得懂,但不知道一个能跑的东西长什么样。这份基于 Qt 的简略版贪吃蛇就是干这个的——纯 C++ 写的单窗口程序,没有网络、没有数据库、没有第三方库,装好 Qt 后打开工程编译就能跑起来。它把 QWidget 自绘、QTimer 定时节拍、键盘事件分发、状态机切换这几件 Qt 里最常用的事全串了一遍,代码量不到五百行,读一遍不会走神。适合刚学完信号槽想找个完整小项目练手的人,也适合要交一个能当场演示的东西的人。下面按我拆项目的顺序,从目录结构一路讲到怎么把它改成自己的版本。
2. 工程骨架与构建:先确认这五个文件各管什么
拿到一个 Qt 小工程,我第一件事不是读 cpp,而是先把文件清单摊开看一眼,判断它是「一个人能读完」还是「进去就出不来」。这份贪吃蛇属于前者,全部源码加上工程文件统共五个,没有任何隐藏的第三方依赖。
2.1 目录结构与文件职责
结构扁平到几乎没有层次,但每个文件的职责切得很干净:
| 文件 | 职责 | 二次开发时的改动频率 |
|---|---|---|
| main.cpp | 创建 QApplication、实例化主窗口、设定初始窗口尺寸 | 几乎不动 |
| snakegame.h | 类声明、方向与状态枚举、全部成员数据 | 加功能时动 |
| snakegame.cpp | 构造函数、节拍函数、绘制函数、键盘事件 | 主要改动都落在这里 |
| snake.pro | qmake 工程描述,声明 QT 模块与源文件 | 加资源、加子目录时动 |
| CMakeLists.txt | CMake 构建脚本,兼容 Qt5 与 Qt6 | 同上 |
这种「一个 QWidget 子类扛下全部逻辑」的写法,正规项目里肯定要被拆成 Model/View 两层,但作为教学工程它有个好处:你不用在四个文件之间反复跳转才能看清一条蛇怎么动。所有状态都在SnakeGame的成员变量里,所有时序都在onTick()里,所有绘制都在paintEvent()里,读代码时脑子里只需要维护一张图。
真正需要留意的只有头文件里的那几个枚举和容器,它们决定了后面所有改动的成本。蛇身用什么容器、方向怎么表示、游戏有几个状态——这三件事定下来,剩下的基本都是填空题。
2.2 一条技术路线选择:QWidget 自绘还是 QGraphicsScene
Qt 里做 2D 小游戏有两条常见路线。一条是QGraphicsScene+QGraphicsItem,每个游戏元素是一个图元,场景负责碰撞检测、Z 序、动画调度;另一条是直接继承QWidget,重写paintEvent()自己画,用QTimer推节拍。
贪吃蛇这个体量,我一般会选自绘。原因很实在:格子最多几百个,不需要图元级的碰撞检测,自己用坐标比对两行代码就完事;QGraphicsScene那套 API 学习成本不低,为了一个二十来行的游戏逻辑引入一整套场景框架,属于用大炮打蚊子。反过来,如果这个项目后面要扩成「多个敌人 + 子弹 + 粒子特效」,那 QGraphicsScene 的调度能力就值回票价了。
选自绘的代价是坐标换算得自己管。窗口尺寸变了、格子大小变了、蛇头画在哪个像素上,全要手算。这也是这类工程最常见的翻车点:写的时候把格子大小定死成 20 像素,换个高分屏或者把窗口拉大,蛇就跑到画面外面去了。后面 3.3 节会专门讲怎么用一个cellRect()把这个换算收敛到一处。
2.3 构建与运行:qmake 和 CMake 各跑一遍
工程同时给了.pro和CMakeLists.txt,两条路都能走。用 Qt Creator 的话,直接打开.pro文件,选一个 Desktop kit,按 Ctrl+R 就完事。用命令行的话:
# 路线一:qmake(老项目、Qt Creator 默认走的这条路) mkdir build-qmake && cd build-qmake qmake ../snake.pro make -j8 # Windows + MSVC 用 nmake 或 jom # 路线二:CMake(Qt6 之后的推荐姿势,Qt5 一样能用) mkdir build-cmake && cd build-cmake cmake .. -DCMAKE_PREFIX_PATH=/path/to/Qt/6.x/gcc_64 cmake --build . --parallelCMAKE_PREFIX_PATH是这里最容易漏的一步。系统里装了不止一套 Qt 的时候(比如 Qt5 和 Qt6 并存),不显式指定前缀,CMake 会自己挑一个,然后你会在编译阶段看到一堆莫名其妙的undefined reference。我习惯在第一次配置时就把路径钉死,后面省心。
对应的 CMake 脚本长这样,关键是用QT_VERSION_MAJOR做一次转发,让同一份脚本在 Qt5 和 Qt6 下都能链接到正确的模块:
cmake_minimum_required(VERSION 3.16) project(snake LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 必须打开,否则带 Q_OBJECT 的类链接不过 # 先按 Qt6 找,找不到再退回 Qt5,把实际版本号存进 QT_VERSION_MAJOR find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) add_executable(snake main.cpp snakegame.cpp snakegame.h # 头文件也要列进来,AUTOMOC 靠它扫描 ) target_link_libraries(snake PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)CMAKE_AUTOMOC ON和「头文件必须写进add_executable」这两条是绑定在一起的。AUTOMOC的工作方式是扫描参与编译的源文件和头文件,找到带Q_OBJECT宏的类,生成moc_xxx.cpp再编进去。如果头文件没列进目标,扫不到,moc就不会跑,最后链接阶段报undefined reference to vtable for SnakeGame。这个错误信息完全没提 moc,新手很容易卡半天,第 5 章会再展开。
.pro那边等价的三行是:
QT += core gui widgets # Qt5 起 widgets 已从 gui 里拆出来,必须单独写 CONFIG += c++17 SOURCES += main.cpp snakegame.cpp HEADERS += snakegame.h程序入口很短,主要是把窗口的初始尺寸和棋盘格数对上,避免开局第一帧就是半格:
#include <QApplication> #include "snakegame.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); SnakeGame w; w.setWindowTitle(QStringLiteral("Snake")); w.resize(24 * 24, 18 * 24); // 24 列 x 18 行,每格 24 像素 w.show(); return app.exec(); }这里的24 * 24和18 * 24必须和头文件里的kCols/kRows保持一致。写死两个数字确实不优雅,但它换来的是「开局画面刚好铺满、没有半格留白」的观感。真要做成可配置的,就在resizeEvent里按窗口大小反算格子尺寸,这点第 6 章会提。
3. 核心模块拆解:一帧蛇从键盘按下到屏幕重绘
Qt 的自绘程序有个固定的循环骨架:事件进来 → 改状态 → 调update()→ Qt 合并重绘请求 → 触发paintEvent→ 画面刷新。游戏逻辑就是往这个骨架里塞一个定时器,让状态自己往前走。这一章按「数据 → 节拍 → 绘制 → 碰撞」四条线把整个链路走一遍。
3.1 数据模型:蛇身容器与坐标表示
蛇身用QVector<QPoint>存,约定front()是蛇头,back()是蛇尾。选QVector而不是std::deque,是因为这个体量的数据(最长也就几百格)连续内存的缓存友好性比头插复杂度更重要,而且QVector和contains()、indexOf()这些 Qt 容器算法配合得顺手,写起来省事。
方向用枚举,不用角度也不用向量:
// snakegame.h #pragma once #include <QWidget> #include <QVector> #include <QPoint> #include <QTimer> #include <QRandomGenerator> class SnakeGame : public QWidget { Q_OBJECT public: explicit SnakeGame(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private slots: void onTick(); // 每个节拍走一步 private: enum class Dir { Up, Down, Left, Right }; enum class State { Ready, Running, Paused, GameOver }; void resetGame(); void spawnFood(); QRect cellRect(const QPoint &p) const; static bool isOpposite(Dir a, Dir b); static constexpr int kCols = 24; // 横向格子数 static constexpr int kRows = 18; // 纵向格子数 QVector<QPoint> m_snake; // 单位是格子坐标,不是像素 QPoint m_food; Dir m_dir = Dir::Right; // 当前生效方向 Dir m_pendingDir = Dir::Right; // 键盘缓冲方向 State m_state = State::Ready; QTimer m_timer; int m_score = 0; int m_best = 0; };有一点必须说清:m_snake里存的是格子坐标(0~23、0~17),不是像素坐标。这个决定看似小事,实际上决定了后面所有代码的复杂度。用格子坐标,碰撞判定就是整数的==比较,食物生成就是在 24×18 的格点里挑一个空格,逻辑里完全不需要关心窗口被拉成什么形状。像素换算只在paintEvent里发生一次,集中在一个函数里。
如果反过来用像素坐标存蛇身,那窗口一 resize,所有历史坐标全部失效,得挨个重算,或者干脆禁止缩放。这是很多人写小游戏时的默认选择,也是后面一堆诡异 bug 的源头。
3.2 QTimer 驱动的游戏节拍
构造函数只做四件事:抢焦点、开定时器、连信号槽、初始化一局。
SnakeGame::SnakeGame(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); // 没有这行,键盘事件根本收不到 setAttribute(Qt::WA_OpaquePaintEvent); // 自己填满整块区域,省一层合成 setMinimumSize(kCols * 12, kRows * 12); // 防止缩得太小格子糊成一团 m_timer.setTimerType(Qt::PreciseTimer); // 节拍要稳,不接受粗糙定时 connect(&m_timer, &QTimer::timeout, this, &SnakeGame::onTick); resetGame(); }Qt::PreciseTimer这个设置值不值得写?对贪吃蛇这种 100~200ms 的节拍其实影响不大,默认的CoarseTimer误差在 5% 以内,肉眼看不出来。但如果你打算把间隔压到 60ms 做「极速模式」,粗糙定时的抖动就会让节奏变得忽快忽慢,玩起来很别扭。反正一行代码的事,我一般直接写上。
setFocusPolicy(Qt::StrongFocus)是这类纯键盘游戏的血泪经验。QWidget默认不接受焦点,按键事件会被父窗口吃掉,表现出来就是「窗口明明显示出来了,怎么按都没反应」。更隐蔽的情况是窗口里放了个按钮或者输入框,焦点被它们抢走,方向键变成了控件导航。这两种现象在排查时都很难往焦点策略上想。
节拍函数是全局最核心的一段,逻辑分六步,顺序不能乱:
void SnakeGame::onTick() { if (m_state != State::Running) return; // 1. 提交缓冲方向:同一节拍内连按多次,只认最后一次合法方向 if (!isOpposite(m_dir, m_pendingDir)) m_dir = m_pendingDir; // 2. 按当前方向算出新蛇头(格子坐标,不涉像素) QPoint head = m_snake.front(); switch (m_dir) { case Dir::Up: head.ry() -= 1; break; case Dir::Down: head.ry() += 1; break; case Dir::Left: head.rx() -= 1; break; case Dir::Right: head.rx() += 1; break; } // 3. 撞墙判定:出界即结束 if (head.x() < 0 || head.x() >= kCols || head.y() < 0 || head.y() >= kRows) { m_state = State::GameOver; m_timer.stop(); update(); return; } // 4. 撞自己:不吃食物时尾巴这一格马上会让出来,所以可以少比一格 const bool eating = (head == m_food); const int checkLen = eating ? m_snake.size() : m_snake.size() - 1; for (int i = 0; i < checkLen; ++i) { if (m_snake.at(i) == head) { m_state = State::GameOver; m_timer.stop(); update(); return; } } // 5. 前进:头插一格;没吃到就砍掉尾巴,长度不变 m_snake.prepend(head); if (eating) { m_score += 10; spawnFood(); if (m_score % 50 == 0) // 每 50 分提一档速度 m_timer.setInterval(qMax(60, m_timer.interval() - 20)); } else { m_snake.removeLast(); } // 6. 请求重绘(Qt 会合并同一轮事件循环里的多次 update) update(); }第 4 步那个checkLen是整段里最容易被写错的地方。蛇头往前一格的时候,如果这一格正好是蛇尾,而这一步又没吃到食物,那么蛇尾会在第 5 步被removeLast()让出来,蛇头填进去完全合法。如果老老实实比对整个m_snake,就会判定成自撞,玩家会发现「贴着尾巴转弯必死」——一个非常经典的、只在自己玩自己写的游戏时才会发现的 bug。
第 6 步的update()是异步的,它只是打个标记,真正的paintEvent会在当前事件循环空闲时触发。这意味着你可以在一帧里调用十次update(),Qt 也只画一次。别把它当成同步的repaint()用。
3.3 paintEvent:把格子坐标画成方块
绘制部分要处理的核心问题是坐标换算。窗口可能被拉成任意比例,格子必须是正方形,画面最好居中。把这三点收敛到一个cellRect()里,其余绘制代码就干净了。
QRect SnakeGame::cellRect(const QPoint &p) const { // 取横竖两个方向能容纳的较小格子,保证格子不会变成扁矩形 const int cell = qMin(width() / kCols, height() / kRows); // 剩余空间均分到两侧,让棋盘始终居中 const QPoint origin((width() - cell * kCols) / 2, (height() - cell * kRows) / 2); return QRect(origin.x() + p.x() * cell, origin.y() + p.y() * cell, cell, cell); }cell同时受宽度和高度约束,所以窗口拉成细长条的时候,棋盘不会跟着变形,只会在左右两侧多出留白。这个写法比setFixedSize()灵活得多,代价是每次重绘都要算一遍——对 432 个格子来说,这点开销可以忽略。
绘制函数本身分成三层:底色棋盘格、食物、蛇身。
void SnakeGame::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); // 格子对齐优先,关掉抗锯齿 p.fillRect(rect(), QColor(24, 26, 32)); // 第一层:隔行隔列的深色棋盘格,作用是让玩家数得清格子 for (int y = 0; y < kRows; ++y) { for (int x = 0; x < kCols; ++x) { if ((x + y) % 2) continue; p.fillRect(cellRect(QPoint(x, y)), QColor(30, 33, 40)); } } // 第二层:食物画成内缩的圆 p.setPen(Qt::NoPen); p.setBrush(QColor(230, 90, 80)); p.drawEllipse(cellRect(m_food).adjusted(3, 3, -3, -3)); // 第三层:从尾到头画蛇,靠 alpha 渐变区分头尾 const int n = m_snake.size(); for (int i = n - 1; i >= 0; --i) { const int alpha = 255 - 110 * i / qMax(1, n); p.setBrush(QColor(80, 200, 140, alpha)); p.drawRoundedRect(cellRect(m_snake.at(i)).adjusted(1, 1, -1, -1), 4, 4); } // 覆盖层:暂停 / 结束提示 if (m_state == State::Paused || m_state == State::GameOver) { p.fillRect(rect(), QColor(0, 0, 0, 150)); p.setPen(Qt::white); QFont f = p.font(); f.setPointSize(qMax(14, height() / 16)); p.setFont(f); p.drawText(rect(), Qt::AlignCenter, m_state == State::Paused ? QStringLiteral("已暂停 空格继续") : QStringLiteral("得分 %1 空格重开").arg(m_score)); } }几个细节值得说。倒序绘制(从尾到头)是必须的,后面的格子会盖住前面的,蛇头最后画,永远是完整的一格,视觉上「头」最突出。alpha按索引线性递减,蛇越长尾巴越淡,玩家一眼就能看出哪头是头。adjusted(1, 1, -1, -1)给每格留 1 像素内边距,相邻格子之间就有了缝隙,看起来像一节一节的,比实心一条好读得多。
提示:如果发现画面在拖动窗口时闪白,把
setAttribute(Qt::WA_OpaquePaintEvent)加上,同时确保paintEvent里第一件事就是fillRect铺满整个rect()。半透明窗口和这个属性不能同时用。
3.4 食物生成与三类碰撞
食物生成看起来是「随机挑个空格」,但写得随意就会在蛇变长之后出问题。最直接的写法是随机取坐标、撞到蛇就重摇:
// 不推荐的写法:蛇长了以后会反复撞车 do { m_food = QPoint(rng() % kCols, rng() % kRows); } while (m_snake.contains(m_food));蛇长到三四百格的时候,满屏只剩几十个空格,这个循环的期望迭代次数会暴涨,表现出来就是「吃到最后一颗食物时卡一下」。正确做法是先建候选表再随机取:
void SnakeGame::spawnFood() { QVector<QPoint> free; free.reserve(kCols * kRows - m_snake.size()); for (int y = 0; y < kRows; ++y) { for (int x = 0; x < kCols; ++x) { const QPoint p(x, y); if (!m_snake.contains(p)) free.append(p); } } if (free.isEmpty()) { // 满屏即通关,顺带兜住除零 m_state = State::GameOver; m_timer.stop(); return; } m_food = free.at(QRandomGenerator::global()->bounded(free.size())); }free.isEmpty()这个分支很多人不写。432 个格子,理论上确实能填满,虽然正常玩到那一步基本不可能,但没有这个保护,bounded(0)的行为是未定义的,可能是崩溃,也可能是永远返回 0 导致食物卡在角落。这类「理论上不会发生」的分支,写一行就能省掉一次玄学调试。
碰撞判定前面提过两类:撞墙看坐标是否越界,撞自己看蛇头新坐标是否落在身体上(要排除即将让出的尾巴)。第三类是吃到食物,实际上不算碰撞,只是一个head == m_food的等值比较。三者的判定顺序有讲究:先墙后身,因为撞墙时蛇头根本不在盘面内,拿它去和身体比较没有意义;吃到食物的判定要放在撞自己之前,因为吃食物会改变身体长度,进而影响「尾巴是否让出」这个前提。
resetGame()把它们串起来:
void SnakeGame::resetGame() { m_snake.clear(); const int cx = kCols / 2, cy = kRows / 2; // 初始三节,头朝右,所以蛇身在头的左边依次排开 m_snake << QPoint(cx, cy) << QPoint(cx - 1, cy) << QPoint(cx - 2, cy); m_dir = m_pendingDir = Dir::Right; m_score = 0; m_state = State::Running; spawnFood(); m_timer.setInterval(150); // 起始节奏:每 150ms 走一格 m_timer.start(); update(); }初始蛇身的排列顺序必须和初始方向一致。如果方向设成Right而蛇身是从头往右排的,第一步走完蛇头就撞上自己的第二节,游戏开局即结束。
4. 输入与状态机:方向键按下去之后发生了什么
逻辑跑通了,剩下最容易出问题的就是输入。贪吃蛇的操作看着简单——四个方向键加一个空格——但真正让玩家觉得「手感好」还是「手感烂」,差别全在几十行键盘处理里。
4.1 keyPressEvent 与方向缓冲队列
最直觉的写法是在keyPressEvent里直接改方向:
// 反面教材:直接改当前方向 case Qt::Key_Up: m_dir = Dir::Up; break;这个写法有两个坑,都会让玩家觉得「这游戏不听使唤」。
第一个是 180 度自杀。蛇正在向右走,玩家按左键,m_dir立刻变成Left,下一个节拍蛇头直接撞进自己脖子里,游戏结束。玩家会很困惑:我明明只是按了下左键。
第二个更隐蔽,和按键速度有关。假设蛇向右走,玩家在一个节拍内飞快地按了「上」再按「左」。按「上」的时候相对当前方向Right是合法的,m_dir变成Up;紧接着按「左」,相对Up也合法,m_dir变成Left。但下一个节拍实际执行的时候,蛇是向右走的位置,一步往左,还是撞自己。判定时看的是「当前方向」和「新方向」的关系,而这个关系在两次按键之间已经悄悄换了一次基准。
解决办法是把「玩家想走的方向」和「蛇当前实际方向」拆成两个变量,键盘只写缓冲,节拍函数在真正走之前做一次合法性校验,只认最后那一个合法方向:
void SnakeGame::keyPressEvent(QKeyEvent *event) { if (event->isAutoRepeat()) { event->ignore(); return; } // 长按不加速 switch (event->key()) { case Qt::Key_Up: m_pendingDir = Dir::Up; break; case Qt::Key_Down: m_pendingDir = Dir::Down; break; case Qt::Key_Left: m_pendingDir = Dir::Left; break; case Qt::Key_Right: m_pendingDir = Dir::Right; break; case Qt::Key_Space: if (m_state == State::Running) { m_state = State::Paused; m_timer.stop(); } else if (m_state == State::Paused) { m_state = State::Running; m_timer.start(); } else { resetGame(); } // Ready / GameOver 都当"开始" update(); return; case Qt::Key_R: resetGame(); return; default: QWidget::keyPressEvent(event); // 不认识的键交还给父类,别吞掉 return; } update(); // 方向键也刷一下,方便做「按下立即反馈」的效果 }isAutoRepeat()那行是必须的。按住方向键不放,系统会以约 30Hz 的频率重复发送keyPressEvent,如果不拦,m_pendingDir会被高频重写。对贪吃蛇来说这通常不致命(同一方向重复赋值没有副作用),但如果后面要做「按一下转向一次」的特殊道具,这行就是分水岭。顺手加上,没有成本。
isOpposite()是个两行的静态函数,把「上下」「左右」两组互斥关系列清楚:
bool SnakeGame::isOpposite(Dir a, Dir b) { return (a == Dir::Up && b == Dir::Down) || (a == Dir::Down && b == Dir::Up) || (a == Dir::Left && b == Dir::Right) || (a == Dir::Right && b == Dir::Left); }注意它比较的是m_dir(当前生效方向)和m_pendingDir(缓冲方向),而不是两次按键之间。所以「上然后左」这种组合里,m_dir在整段时间内一直是Right,按「上」时不互斥,缓冲记下Up;按「左」时m_pendingDir被覆盖成Left,而Left相对Right是互斥的——节拍函数里这一步校验会失败,于是方向保持Right,玩家少转一次弯,但不会死。这就是缓冲队列的意义:宁可丢一次输入,也不能让玩家莫名其妙送命。
4.2 状态枚举与切换条件
State一共四个值,看着少,但每个都承担了明确的职责:
| 状态 | 定时器 | 画面 | 进入条件 | 离开方式 |
|---|---|---|---|---|
| Ready | 停止 | 静止初始画面 | 构造时 | 空格 → Running |
| Running | 运行 | 正常游戏 | 空格 / resetGame | 撞墙 / 撞身 → GameOver,空格 → Paused |
| Paused | 停止 | 半透明遮罩 | Running 中按空格 | 空格 → Running |
| GameOver | 停止 | 遮罩 + 得分 | 碰撞判定命中 | 空格 / R → Ready |
把状态和定时器绑在一起是关键设计。Paused和GameOver都调用m_timer.stop(),而不是在onTick()里判断「如果暂停就 return」。两种做法看着效果一样,但前者的 CPU 占用是真降下来了,后者定时器还在 150ms 一次地空转,笔记本上能明显感觉到风扇转起来。onTick()开头那句if (m_state != State::Running) return;只是兜底,不是主要机制。
Ready和GameOver在按空格时都走resetGame(),这样玩家开局和重开是同一个操作,不用记两套快捷键。Running和Paused之间切换时会调m_timer.start(),注意start()不带参数时会沿用上一次的 interval——这一点很关键,暂停再继续之后速度不会回到 150ms,而是保持暂停前的节奏。
4.3 计分、加速与难度曲线
分数规则很简单:吃一颗加 10 分。真正的设计在速度曲线上。
if (eating) { m_score += 10; spawnFood(); if (m_score % 50 == 0) // 每 50 分提一档 m_timer.setInterval(qMax(60, m_timer.interval() - 20)); }m_timer.interval() - 20是线性提速,每档快 20ms。qMax(60, ...)是速度下限,防止间隔被压到 0 之后定时器变成忙等、程序直接卡死。60ms 已经是一秒十六格,人眼勉强跟得上,再快就没法玩了。
这个曲线有个可以调的地方:线性提速在前期手感很好(150 → 130 → 110),到后期就有点钝(100 → 80 → 60),玩家感觉不到明显变难。如果想做成「越往后越紧张」的体验,可以改成按比例缩:
// 按比例缩,前期慢、后期快,曲线更陡 if (m_score % 50 == 0) m_timer.setInterval(qMax(60, int(m_timer.interval() * 0.85)));两行代码的差别在测试阶段很明显。我一般两种都试一遍,哪只手感好留哪个——数值调参这种事,纸上算不出来,得自己玩。
顺带提一句m_best。最高分不参与游戏逻辑,只在GameOver遮罩上显示,但它是这个工程里唯一需要跨进程保存的数据。最简单的做法是用QSettings落盘,两行就够:
// 读:构造时或 resetGame 里调一次 QSettings s(QStringLiteral("demo"), QStringLiteral("snake")); m_best = s.value(QStringLiteral("best"), 0).toInt(); // 写:GameOver 判定成立、score 超过 best 时 if (m_score > m_best) { m_best = m_score; s.setValue(QStringLiteral("best"), m_best); }QSettings的优势是不用自己管文件路径,Windows 下写注册表,Linux 下写~/.config,macOS 下写 plist,同一份代码三平台都能跑。缺点是存进去的东西用户改不了也看不见,做排行榜之类的功能就得换成 JSON 文件。
5. 避坑与排查:编译过了,跑起来才刚开始
这类小工程的 bug 有个共同特征:编译期一切正常,运行起来要么没反应,要么行为诡异。下面几条是我拆这类 Qt 小游戏时踩过或者见别人踩过的,按「现象 → 原因 → 解决」写清楚。
5.1 编译与链接阶段
现象一:undefined reference to vtable for SnakeGame,或者LNK2001: 无法解析的外部符号。
原因基本只有一个:类里写了Q_OBJECT宏,但 moc 没跑。Q_OBJECT会展开出一堆虚函数声明(metaObject()、qt_metacall()等),定义要靠 moc 生成。构建系统不知道这个类需要 moc,就不会生成moc_snakegame.cpp,链接时自然找不到定义。CMake 下最常见的触发点是「头文件没列进add_executable」或者「CMAKE_AUTOMOC没开」;qmake 下则是「HEADERS里漏了 snakegame.h」。
解决:CMake 侧确认set(CMAKE_AUTOMOC ON),并把snakegame.h写进目标源文件列表;qmake 侧补上HEADERS += snakegame.h。改完之后必须重新跑一次 cmake 配置,不能只make——AUTOMOC 的扫描列表是在配置阶段生成的,光编译不会更新。这条我吃过亏,改完头文件直接make,报同样的错,一度以为是缓存问题把 build 目录删了重建才好。
现象二:fatal error: QWidget: No such file or directory。
原因:.pro里只写了QT += core gui,没写widgets。Qt4 时代QWidget确实在gui模块里,但 Qt5 起widgets被拆出来单独一个模块,Qt6 里拆得更彻底。
解决:QT += core gui widgets,或者简写成QT += widgets(core和gui是默认值)。CMake 侧对应的是find_package(... COMPONENTS Widgets)和target_link_libraries(... Qt::Widgets),两处都得有。
现象三:CMake 配置成功,编译也过,但运行时报This application failed to start because no Qt platform plugin could be initialized。
原因:Windows 上直接双击build/snake.exe运行,缺少platforms/qwindows.dll这个插件,程序找不到图形后端。在 Qt Creator 里点运行不会出现,因为 IDE 会帮你改PATH。
解决:命令行运行时把 Qt 的bin目录加进PATH;或者用windeployqt snake.exe把依赖的 dll 和插件目录拷到 exe 旁边。后者是发布版必须做的,windeployqt会自动把platforms、styles、imageformats这些子目录铺好,漏了任何一个都会在别人的机器上白屏。
5.2 运行期的坑
现象四:窗口出来了,画面也正常,但按方向键完全没反应。
原因:QWidget默认的focusPolicy是Qt::NoFocus,不接受键盘焦点,keyPressEvent压根不会被调用。如果窗口里有按钮之类的子控件,焦点还会被它们抢走,方向键变成了控件之间的导航。
解决:构造函数里加setFocusPolicy(Qt::StrongFocus)。如果窗口里确实有会抢焦点的控件,在需要操作游戏的时候调一次setFocus()。排查这类问题有个笨办法但很有效:在keyPressEvent第一行加qDebug() << event->key();,如果一行都不打,就是事件根本没进来,不要再去怀疑方向判断逻辑。
现象五:蛇一按方向键就撞死自己,或者在一个节拍内快速按两个方向必死。
原因:直接修改了m_dir(当前生效方向),没有做缓冲和合法性校验,180 度掉头立刻生效。
解决:拆成m_dir/m_pendingDir两个变量,键盘只写缓冲,onTick()里先比对isOpposite()再提交。详细实现见 4.1 节。这个改动的另一个好处是让输入和帧率解耦——键盘事件来得再密,也只在节拍点生效一次。
现象六:窗口拉大之后蛇身错位、蛇跑到画面外面去,或者格子变成扁矩形。
原因:绘制时用了写死的像素值,比如x * 20,而窗口尺寸由用户控制。
解决:把所有像素换算收敛到cellRect()一个函数里,用qMin(width() / kCols, height() / kRows)现算格子大小,再用剩余空间算居中偏移。如果实在不想处理缩放,那就干脆setFixedSize(kCols * cell, kRows * cell)锁死窗口,至少不会错位。
现象七:暂停之后再继续,蛇的速度忽然变快了(或者变回初始速度)。
原因:m_timer.start()带了参数会重置 interval,不带参数才沿用上一次的值。写成m_timer.start(150)就会把辛苦积累的加速全抹掉。
解决:暂停恢复统一用无参start();只有在resetGame()里才显式setInterval(150)重置节奏。这类「参数带不带」的坑 Qt 里还有不少,比如QTimer::start()、QLabel::setNum(),多看两眼签名能省很多事。
6. 二次开发:把它改成你自己的版本
工程跑通之后,改造成本最低的三件事是换皮、加障碍、做关卡。
换皮最省事的做法是把颜色和尺寸从paintEvent里抽出来,集中到一个结构体里:
struct Theme { QColor bg{24, 26, 32}, grid{30, 33, 40}, food{230, 90, 80}, snake{80, 200, 140}; int cellMin = 12; // 格子最小像素,决定窗口能缩多小 bool rounded = true; // 蛇身是否圆角 };抽出来之后,主题切换就是换一个Theme实例再update(),不用全局搜索QColor。
加障碍物则要给盘面加一层数据结构。我一般用一个QSet<QPoint> m_walls,spawnFood()里建候选表时把墙也排除掉,onTick()的碰撞判定里多加一条m_walls.contains(head)。真正要注意的是初始蛇身的摆放位置得避开墙,否则开局即死。
做关卡的话,把墙、初始蛇位、目标分数塞进一个 JSON:
{ "cols": 24, "rows": 18, "startInterval": 150, "walls": [[5,5],[5,6],[5,7],[18,12],[19,12]], "startSnake": [[12,9],[11,9],[10,9]], "target": 200 }用QJsonDocument::fromJson()读进来,一个Level结构体接住,resetGame()里按关卡数据初始化而不是写死。Qt 自带 JSON 模块,不用引第三方库,这是选 Qt 做小游戏的一个隐性便利。
最后说个验证习惯。这类定时驱动的游戏,逻辑正确性很难靠肉眼玩出来,尤其是「贴着尾巴转弯」和「连续快速按键」这两种边界。我现在的做法是给onTick()留一个可注入的入口——把m_snake、m_dir、m_food做成可以被测试代码直接设定的状态,然后用QTest::qWait控节奏,跑几个固定场景断言结果。麻烦是麻烦了点,但比每次改完代码手动玩十遍靠谱。从那以后我每次动onTick()里的判定顺序,都强制先把这三个场景跑一遍再上手动测试,成功省下过好几次「改一处崩三处」的返工。希望帮到你。
本文还有配套的精品资源,点击获取