ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux下Qt Widgets贪吃蛇小游戏开发与避坑指南

Linux下Qt Widgets贪吃蛇小游戏开发与避坑指南

简介:面向 Linux 平台学习 C++ 与 Qt 图形界面开发的入门到进阶读者,这套贪吃蛇小游戏工程提供了可直接参考的完整实现,涵盖多界面与菜单切换、游戏画面绘制与键盘控制、等级选择与管理、背景音乐播放切换、英雄榜分数统计与高分更新,以及帮助信息展示等模块,适合用来理解 Qt 信号槽、事件处理、资源组织与 SQLite 数据持久化的基本用法。压缩包共 71 个文件,约 17.22MB,包含 cpp 与 h 源码头文件、ui 界面描述、png/jpg 图像素材、wav 音效、pro 工程与 qrc 资源文件及 db 数据库等,覆盖开发、素材与运行数据。工程将游戏逻辑与界面层分离,便于按模块阅读,也包含资源打包与编译配置,可帮助读者理解 Linux 下 Qt 项目的构建流程,并作为课程设计或小型桌面应用的参考原型。已有 1475 人学习。

1. 需求先立住:Linux 下 Qt 贪吃蛇小游戏要交付的到底是什么

课设任务下来那天,多数人第一反应是「贪吃蛇还要做?」。真到交付才发现,评审看的并不是蛇会不会动,而是一项完整软件被拆解设计的能力:Linux 下能不能一条命令编出来,Qt 的定时器和信号槽有没有用对,撞到自己时判定准不准,窗口卡不卡。

这套 Linux 下 Qt 开发贪吃蛇小游戏的方案就围绕这几个交付点搭起来:技术栈是 C++ 加 Qt Widgets,运行在 Linux 桌面环境,产物是一个可编译、可演示、能逐行讲清楚的单窗口程序。它适合三类人:要交 C++/Qt 课程设计的同学,做 Qt 入门练手的开发者,以及需要一份改改就能交的完整骨架的人。下面从选型一路写到答辩现场怎么演示。

2. 技术选型:Qt Widgets、QTimer 与 CMake 的三件套怎么定

2.1 功能清单与边界:一局游戏要拆成几件事

动手写类之前先把功能钉死,否则写到一半不断加需求,最后结构一定是乱的。我一般会把功能分成「必须做」和「有余力再做」两档,必须做的部分直接对着验收点来,课堂上演示不心虚。

功能档位验收方式
蛇按固定节拍移动必须松开键盘也在动
方向键改变方向必须四个方向都能转
吃食物增长一节并加分必须分数与长度同步
撞墙、撞自身判定结束必须两种情况都触发
暂停 / 继续 / 重开必须演示中途可打断
速度随分数递增有余力吃若干食物后明显变快
最高分持久化有余力退出重进仍有记录

表格里的「必须」项对应代码里最少的五个函数:初始化、移动、判定、生成食物、绘制。把边界划在这里有个好处:这五个函数写完之后,剩下的功能都是加料,不影响主干。

把边界定成「单机、单窗口、不联网、不涉及文件存档以外的东西」,还有一个现实原因:范围越小,代码逻辑越容易在答辩时被完整讲清楚。评审问到的每一个分支你都能答上来,比多做两个花哨功能更值钱。

2.2 Qt Widgets 与 Qt Quick 的取舍

Qt 下做小游戏有两条路:Widgets 自绘,或者 QML 快速搭界面。很多人看 QML 动画顺滑就想选它,结果做课设时翻了车——QML 会把大量逻辑写进 .qml 文件里,C++ 部分只剩一个壳,答辩时老师说「讲讲你的 C++ 类设计」,你只能指着几十行 js 函数。

对比一下三种常见路子:

方案上手成本逻辑归属课设适配度
Qt Widgets + QPainter中,需要会重写事件全部在 C++ 类里高,能讲清设计
Qt Quick / QML低,界面写得快逻辑分散在 qml 与 js中,C++ 内容偏薄
终端 + ncurses低C++ 里低,界面展示单薄

Widgets 的价值在于它的执行链路非常线性:QTimer 到点触发槽函数,槽函数改数据,改完调用 update(),Qt 再回调 paintEvent 重画。这条链路你可以完整写在答辩 PPT 上,每一环都有对应的 C++ 代码。QPainter 画方块这件事本身也不复杂,贪吃蛇的图形无非是一堆 fillRect。

真正的取舍点不是画得好不好看,而是「代码能不能被讲解」。选 Widgets 等于把难度放在设计上,把可讲解性拉到最高。

2.3 Linux 下的工程结构与构建方式

目录别搞太深,课设规模的项目四个文件就够:入口、窗口类头文件、窗口类实现、构建脚本。CMake 比 qmake 更通用,主流发行版仓库里的 Qt 开发包都带 CMake 支持,老师换台机器也能编。

cmake_minimum_required(VERSION 3.16) project(SnakeGame LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Qt 的信号槽需要 moc 预处理,这一行不能漏 set(CMAKE_AUTOMOC ON) # 按本机安装的 Qt 版本选一行,Qt6 用下面这行 find_package(Qt6 REQUIRED COMPONENTS Widgets) # 老环境把上面一行注释掉,换成 find_package(Qt5 REQUIRED COMPONENTS Widgets) add_executable(SnakeGame main.cpp snakewidget.h snakewidget.cpp ) target_link_libraries(SnakeGame PRIVATE Qt6::Widgets) # Qt5 环境对应改成 Qt5::Widgets

几个参数的用意:CMAKE_AUTOMOC ON负责扫描带Q_OBJECT宏的头文件并生成 moc 代码,漏掉这一行会出现「undefined reference to vtable」这类链接错误,是新手最常见的编译失败原因。CMAKE_CXX_STANDARD 17是为了用上结构化绑定和enum class,Qt6 本身就要求 C++17 及以上。find_package那一行要跟你机器上装的 Qt 大版本对齐,装了 Qt5 却写 Qt6 会在配置阶段就报找不到包。

编译三步走,在项目目录下执行:

mkdir -p build && cd build # 把构建产物隔离到 build 目录,源码目录保持干净 cmake .. # 生成 Makefile,缺 Qt 开发包会在这一步报错 cmake --build . -j4 # 并行编译,-j4 按 CPU 核数自行调整 ./SnakeGame # 运行

如果cmake ..阶段报找不到 Qt 的配置文件,通常是开发包没装全,用发行版包管理器装带-dev或-devel后缀的 Qt 基础模块包即可。运行阶段报「cannot connect to display」则是当前终端不在图形会话里,这跟代码无关,换到桌面终端再跑。

3. 总体架构设计:三层结构与五个类的职责划分

3.1 三层结构:数据层、逻辑层、渲染层

贪吃蛇麻雀虽小,如果所有代码堆进一个paintEvent里,改一个速度参数都要通读全文件。我的做法是分三层,层与层之间靠一个窗口类串起来。

数据层只放状态:蛇身坐标列表、当前方向、食物坐标、分数、是否结束。这一层不包含任何 Qt 界面代码,理论上可以单独拿出来做单元测试。逻辑层负责「一帧该发生什么」:算出新蛇头、判断碰撞、处理吃食物和增长、决定是否加速。渲染层只做一件事:把数据层里的坐标翻译成屏幕上的方块和文字。

三层之间的关系是单向的:输入事件改方向,定时器触发逻辑层跑一帧,逻辑层改完数据层后通知界面刷新,渲染层读数据层画图。渲染层永远不改状态,这条约束守住,调试时会省很多事——画面不对,问题一定在数据或逻辑,不在画法。

3.2 五个类的职责与协作关系

按上面三层拆下来,实际代码里是五个部分:

名称类型职责
main函数创建 QApplication,构造并显示主窗口
SnakeWidget类(继承 QWidget)持有全部状态,串联定时器、输入与绘制
GameConfig命名空间或常量头网格尺寸、格子像素、初始速度、加速步长
Point / Direction结构体与枚举坐标与方向的类型定义,替代裸 int
槽函数组SnakeWidget 成员tick、spawnFood、resetGame 等逻辑单元

把常量抽到 GameConfig 是血泪经验:网格大小 20×20、格子像素 20、初始节拍 150 毫秒,这三个数字在判定、绘制、窗口尺寸计算里都要用。写死在各处,改一次网格就得全局搜索替换,漏一处就出现「蛇画在格子中间」的诡异画面。

SnakeWidget 同时扮演视图和控制器,对课设规模来说是合理的,因为状态只有一份,拆成 Model 和 View 两个类要来回传引用,反而增加理解成本。但如果你的任务书明确要求分层,就把 3.1 里的数据层和逻辑层单独抽成GameModel类,SnakeWidget 只保留paintEvent和keyPressEvent,模型通过信号通知视图刷新。

3.3 关键数据结构:坐标、蛇身容器与方向枚举

数据结构定错,后面全是补丁。这里我用QList<Point>存蛇身,头部固定在下标 0,尾部在末尾,移动就是「头部插入、尾部删除」,两端操作都是 O(1) 摊还。

// snakewidget.h 片段 #include <QWidget> #include <QList> #include <QTimer> #include <QPoint> // 用强类型枚举,避免把 Direction 当 int 随意做算术 enum class Direction { Up, Down, Left, Right }; class SnakeWidget : public QWidget { Q_OBJECT public: explicit SnakeWidget(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; // 渲染层入口 void keyPressEvent(QKeyEvent *event) override; // 输入层入口 private slots: void tick(); // 逻辑层:推进一帧 void spawnFood(); // 逻辑层:生成合法食物 private: static constexpr int kGridSize = 20; // 20×20 网格 static constexpr int kCellPx = 20; // 每格 20 像素 QList<QPoint> m_snake; // 蛇身,下标 0 为蛇头 QList<Direction> m_pending; // 待消费的方向输入,最多缓存两个 Direction m_dir = Direction::Right; QPoint m_food; QTimer *m_timer = nullptr; int m_score = 0; int m_interval = 150; // 当前节拍,毫秒 bool m_running = false; bool m_paused = false; void resetGame(); bool willCollide(const QPoint &next) const; };

QPoint直接当坐标用,省掉自定义结构体;m_pending用列表缓存方向输入是关键设计,后文 4.4 会讲为什么单个变量不够。m_interval单独存一份而不直接读m_timer->interval(),是为了加速时能做「不低于下限」的钳制。willCollide声明成 const 成员函数,方便在任何地方做预判而不担心副作用。

4. 核心模块实现:游戏循环、蛇身队列与碰撞判定

4.1 定时器驱动的主循环

游戏要有节拍,Qt 里最直接的就是 QTimer。在构造函数里建好、连好槽、启动,之后每到一个间隔就自动调一次tick()。

SnakeWidget::SnakeWidget(QWidget *parent) : QWidget(parent) { // 窗口尺寸由网格数 × 格子像素推导,改配置时窗口自动跟着变 setFixedSize(kGridSize * kCellPx, kGridSize * kCellPx); setWindowTitle(QStringLiteral("Snake")); setFocusPolicy(Qt::StrongFocus); // 保证能收到键盘事件 m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, &SnakeWidget::tick); resetGame(); // 初始化蛇身、食物、分数 m_timer->start(m_interval); }

setFocusPolicy(Qt::StrongFocus)这行看着不起眼,漏了它就会出现「点了开始但方向键没反应」——窗口没拿到焦点,keyPressEvent根本不会进来。setFixedSize让窗口尺寸不可拖拽,是为了让坐标换算保持恒定,第 5 章会讲缩放带来的坑。

m_timer的父对象设成this,是为了让 Qt 的对象树负责释放,不用手写 delete。

4.2 蛇身移动:头进尾出的队列实现

一帧的核心就三步:算新头、判碰撞、决定是否删尾。

void SnakeWidget::tick() { if (!m_running || m_paused) return; // 消费一个待处理方向,保证转向与移动同步生效 if (!m_pending.isEmpty()) { m_dir = m_pending.takeFirst(); } QPoint head = m_snake.first(); switch (m_dir) { case Direction::Up: head.ry() -= 1; break; case Direction::Down: head.ry() += 1; break; case Direction::Left: head.rx() -= 1; break; case Direction::Right: head.rx() += 1; break; } // 先判碰撞,再决定要不要改动蛇身 if (willCollide(head)) { m_running = false; m_timer->stop(); update(); // 触发重绘,把结束状态画出来 return; } bool eat = (head == m_food); m_snake.prepend(head); // 头部插入新坐标 if (!eat) { m_snake.removeLast(); // 没吃到就删尾,长度不变 } else { m_score += 10; maybeSpeedUp(); // 吃到食物后尝试加速 spawnFood(); } update(); }

顺序很重要:先算头,再判碰撞,最后才动容器。如果先把头插进去再判,判断自身碰撞时新头就在列表里,会自己撞自己,这是第 5 章要重点说的坑。

吃到食物时「插头不删尾」让长度自然加一,这个技巧比维护长度变量再调整容器简单得多。

4.3 碰撞检测与食物生成的合法性

碰撞分两种:越界和撞身。越界看坐标是否落在[0, kGridSize)区间内;撞身看新头是否落在蛇身坐标里。这里有个细节——尾巴那一格在下一帧会空出来,所以严格来说头进入当前尾格是合法的,但为了逻辑简单、视觉上也不违和,我一般直接把它算作碰撞,代价是偶发的「贴尾即死」,课设场景完全能接受。

bool SnakeWidget::willCollide(const QPoint &next) const { // 越界判定:四个边界任意一个穿透即结束 if (next.x() < 0 || next.x() >= kGridSize || next.y() < 0 || next.y() >= kGridSize) { return true; } // 自身碰撞:遍历蛇身,命中即为真 return m_snake.contains(next); }

食物生成最容易写出死循环:随机到空格子为止。网格填满时,这种写法会无限转。正确做法是先收集所有空格,再随机取一个。

void SnakeWidget::spawnFood() { QList<QPoint> freeCells; freeCells.reserve(kGridSize * kGridSize - m_snake.size()); for (int y = 0; y < kGridSize; ++y) { for (int x = 0; x < kGridSize; ++x) { QPoint p(x, y); if (!m_snake.contains(p)) freeCells.append(p); } } if (freeCells.isEmpty()) { // 满屏即通关,直接结束,不给死循环机会 m_running = false; m_timer->stop(); return; } m_food = freeCells.at(QRandomGenerator::global()->bounded(freeCells.size())); }

QRandomGenerator是 Qt 提供的随机源,比qrand()更现代,也不用自己播种。reserve只是减少扩容次数,网格最大 400 个格子,性能影响可以忽略,写上是习惯。

4.4 方向输入:缓存队列防反向

如果按下方向键就直接改m_dir,会出一个经典 bug:蛇向右移动时,玩家在同一个节拍内连按「上」和「左」,m_dir先变成上再变成左,下帧执行「左」时蛇头直接撞进自己脖子,判定为死亡。玩家会觉得「我明明转了个弯」。

解决办法是把输入放进队列,每帧只消费一个,并且在入队时就校验「是否与队尾方向相反」。

void SnakeWidget::keyPressEvent(QKeyEvent *event) { Direction want; switch (event->key()) { case Qt::Key_Up: want = Direction::Up; break; case Qt::Key_Down: want = Direction::Down; break; case Qt::Key_Left: want = Direction::Left; break; case Qt::Key_Right: want = Direction::Right; break; case Qt::Key_Space: // 空格暂停/继续 if (m_running) m_paused = !m_paused; return; case Qt::Key_R: // R 键重开 resetGame(); m_timer->start(m_interval); return; default: QWidget::keyPressEvent(event); // 其余按键交还父类处理 return; } // 与队尾(或当前方向)相反则丢弃,避免一帧内 180 度掉头 Direction base = m_pending.isEmpty() ? m_dir : m_pending.last(); bool opposite = (base == Direction::Up && want == Direction::Down) || (base == Direction::Down && want == Direction::Up) || (base == Direction::Left && want == Direction::Right) || (base == Direction::Right && want == Direction::Left); if (opposite) return; // 最多缓存两个输入,防止连按导致转向延迟过大 if (m_pending.size() < 2) m_pending.append(want); }

对比基准选「队尾方向」而不是「当前方向」,是因为队列里可能已经存了一个未生效的转弯,拿当前方向比会漏掉连续两次同向掉头的情况。缓存上限设为 2 是手感上的折中,缓存太多会让操作明显滞后。

4.5 绘制层:QPainter 与网格对齐

渲染层只读数据,逻辑很薄:

void SnakeWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), QColor(24, 26, 32)); // 背景 // 食物画成圆形,和蛇身方块区分开 p.setBrush(QColor(230, 90, 80)); p.setPen(Qt::NoPen); p.drawEllipse(QRect(m_food.x() * kCellPx + 2, m_food.y() * kCellPx + 2, kCellPx - 4, kCellPx - 4)); // 蛇头用亮色,蛇身逐节变暗,视觉上有方向感 for (int i = 0; i < m_snake.size(); ++i) { const QPoint &c = m_snake.at(i); int shade = qMax(90, 200 - i * 4); p.setBrush(i == 0 ? QColor(120, 220, 140) : QColor(60, shade, 100)); p.drawRect(c.x() * kCellPx, c.y() * kCellPx, kCellPx - 1, kCellPx - 1); } // 结束或暂停时叠一层提示文字 if (!m_running || m_paused) { p.setPen(Qt::white); p.drawText(rect(), Qt::AlignCenter, m_running ? QStringLiteral("Paused") : QStringLiteral("Game Over")); } }

方块的宽高画kCellPx - 1而不是kCellPx,留 1 像素缝隙让每节蛇身有边界感,不然整条蛇是一块大色块。所有坐标都由「格子索引 × 格子像素」算出,不存视图坐标,这样改格子大小不影响逻辑。

paintEvent里不要做任何状态修改和耗时计算,它可能被系统频繁调用。需要动的数据在上游算好,这里只负责画。

5. 避坑与排查:五处最容易在演示现场翻车的地方

5.1 按了方向键蛇没反应

现象:程序能跑,蛇自动往前走,但键盘完全没有反馈,按暂停也没用。

原因基本只有一个:窗口没拿到键盘焦点。QWidget默认的焦点策略是Qt::NoFocus,没有焦点,keyPressEvent不会被派发到这个窗口。

解决:构造函数里加setFocusPolicy(Qt::StrongFocus),如果窗口里还嵌了按钮等其他控件,再加setFocus()主动抢一次。另外要确认没有在别处误调grabKeyboard()或把事件在eventFilter里吃掉。判断方法很简单,在keyPressEvent第一行加一句打印,按键盘有输出说明事件到了,没输出就是焦点问题。

5.2 蛇吃到食物后立刻判定自撞

现象:偶尔吃到食物后分数加了,但同一下游戏就结束了,位置看上去根本没撞到东西。

原因:碰撞判定的顺序错了。如果先把新头prepend进蛇身再调用willCollide,新头自己就在列表里,m_snake.contains(next)必然返回真。

解决:把碰撞判定放在修改容器之前,用算出来的临时坐标去判,只有判定通过才prepend。这个顺序在 4.2 的代码里是硬约束,改代码时容易顺手调换,改完一定要重跑一遍吃食物的场景。

5.3 分数越高游戏越卡

现象:前几分钟很流畅,吃到十几个食物后明显掉帧,拖动窗口也会闪。

原因:多半是在paintEvent里做了重活,比如每次都重新构造QPainter之外的画刷、每帧调用spawnFood做全量遍历,或者更常见的——在tick()里调了repaint()而不是update()。repaint()是同步立即重绘,会在一个节拍内强制走完整个绘制流程,连续调用就把主线程堵住了。

解决:统一用update(),它是异步的,Qt 会把多次调用合并成一次绘制事件。同时检查paintEvent里有没有new、文件读写、字符串拼接,统统挪到上游。

5.4 换了台机器编译不过

现象:自己机器上好好的,拷到实验室机器上cmake ..就报找不到 Qt6 或者一堆 undefined reference。

原因:两台机器的 Qt 大版本不一致,或者对

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进