简介:基于Linux与Qt开发的贪吃蛇小游戏完整工程,面向C++/Qt初学者、Linux开发爱好者以及需要课程设计的在校生。项目实现了界面显示、游戏逻辑、等级管理、背景音乐、英雄榜和帮助等核心功能,交互完整,可用于理解Qt绘图、事件处理与多模块协同开发。资源打包为gz格式,共71个文件,包含cpp/h源码、ui窗体、qrc资源、makefile构建脚本,以及png/jpg图片、wav音频等素材,整体约17.22MB。目前已有1474人浏览学习。压缩包内附有编译生成的o目标文件、可执行程序qt_snake和player.db记录库,既可直接运行体验,也可对照源码梳理Qt工程结构,对理解贪吃蛇的状态更新、按键控制与分数持久化很有帮助。
1. 基于Linux下Qt开发贪吃蛇:一个小项目背后的完整Qt落地路径
很多开发者第一次接触Qt,要么卡在“教程看得懂、自己写不出”,要么做完一个界面Demo就不知道下一步该干什么。贪吃蛇恰好是一个“麻雀虽小、五脏俱全”的项目:它有游戏循环、有碰撞检测、有随机生成、有键盘事件、有界面刷新,还涉及定时器与状态管理——这些正是Qt桌面应用开发里最常用的能力组合。在Linux下用Qt从零写贪吃蛇,不是简单地翻译一遍别人的代码,而是要把QPainter绘图、QTimer驱动、QKeyEvent事件响应、以及QVector或链表结构组织蛇身数据这几条线串起来,形成一个能独立编译、可玩、可扩展的小游戏。这个项目很适合两类人:一类是刚学完C++语法、想找一个不太复杂但完整的Qt练手目标;另一类是工作里要用Qt做上位机或工具软件,想先通过一个小游戏把事件循环和绘图机制摸透。读完这篇,你能从环境配置一路走到一个可以拿出去展示的贪吃蛇程序,并且知道哪些地方最容易出问题。
2. Linux下的Qt开发环境:从安装到跑通第一个窗口
2.1 选择Qt版本与安装方式:不要一上来就装最新版
在Linux上装Qt,常见做法是通过Qt在线安装器安装完整套件,或者用系统包管理器直接装开发库。我一般建议新手优先用系统包管理器,因为依赖处理更省心。以Debian/Ubuntu系为例:
sudo apt update sudo apt install qt6-base-dev qt6-tools-dev-tools qt6-multimedia-dev这段命令安装的是Qt 6的基础开发包和编译工具。安装完成后,用qmake --version或qtpaths6 --qt-version确认版本。需要说明的是:qt6-base-dev提供核心库和头文件,qt6-tools-dev-tools提供qmake等构建工具,qt6-multimedia-dev在贪吃蛇里不是必需的,但如果你后续想加音效,留着更方便。
如果你更习惯用CMake,还需要额外安装qt6-base-dev时确保CMake能找到Qt6的包配置。Qt 6的CMake集成比Qt 5更完善,find_package(Qt6 REQUIRED COMPONENTS Widgets)是标准做法。新手不用纠结选qmake还是CMake——项目超过三个文件,我建议CMake;贪吃蛇这种规模,两者都能胜任。
2.2 用qmake创建最小可运行项目:不用Qt Creator也能跑
很多教程默认你打开Qt Creator点几下就生成工程,但命令行方式更透明,出了问题也更好排查。手动创建一个最小项目,目录结构如下:
snake_game/ ├── snake.pro ├── main.cpp └── snakewidget.h / snakewidget.cppsneak.pro文件内容:
QT += core gui widgets TARGET = snake_game TEMPLATE = app SOURCES += main.cpp snakewidget.cpp HEADERS += snakewidget.h在项目根目录执行:
qmake snake.pro make -j$(nproc) ./snake_game这里qmake会根据.pro文件生成Makefile,make执行编译链接。-j$(nproc)用满CPU核心数加速编译。如果弹出空白窗口,说明环境链路没问题。此时窗口还是空白的,下一步就把贪吃蛇的逻辑和绘制填进去。
2.3 为什么我推荐用QWidget而不是QML
Qt开发游戏有两个方向:QWidget(传统控件绘图)和QML(声明式界面)。贪吃蛇这种2D格子游戏,用QWidget + QPainter的方式更直观——所有绘制逻辑集中在一个paintEvent里,蛇身、食物、背景都用QPainter对象一笔笔画出来。QML的动画和状态管理虽然强,但文件多、概念抽象,对只想搞懂Qt事件循环的新手反而是负担。QWidget方案还有一个好处:调试时可以在paintEvent里加qDebug()输出坐标,刷新流程看得清清楚楚。QML在这类小项目里带来的收益不大,反而增加了文件跳转的认知成本。
3. 贪吃蛇的核心数据结构与游戏逻辑:先写能跑的规则引擎
3.1 用QQueue还是QVector?蛇身的存储选型
蛇身本质上是一个按顺序排列的坐标序列,头部决定方向,身体跟随。最直接的实现是每次移动时,从尾部去掉一格、向头部方向加一格。在Qt里,用QVector<QPoint>存坐标,操作方式是:头部push_front新坐标,尾部pop_back删掉旧坐标;吃到食物时,只push_front不删尾部,长度自然增加。
QVector<QPoint> snake; snake.prepend(newHead); // 新头部 if (ateFood) { // 不用 pop_back,长度+1 } else { snake.pop_back(); // 保持长度 }选择QVector而不是链表,是因为贪吃蛇的蛇身长度通常在几百格以内,QVector的连续内存访问更快,代码也更简洁。QPoint是Qt内置的整数坐标点,比自定义结构体省事。注意prepend在QVector里是O(n)操作,但蛇身长度小,完全不用担心性能。
3.2 移动与碰撞:方向状态机与边界判定
移动逻辑的核心是“方向优先于位置”:玩家按键改变方向,但真正让蛇移动的是定时器触发的tick。每次tick,根据当前方向计算目标坐标:
enum Direction { Up, Down, Left, Right }; Direction dir; // 当前移动方向 QPoint head = snake.first(); switch (dir) { case Up: head.setY(head.y() - 1); break; case Down: head.setY(head.y() + 1); break; case Left: head.setX(head.x() - 1); break; case Right: head.setX(head.x() + 1); break; }碰撞检测有两类:墙壁碰撞和自身碰撞。墙壁碰撞只需判断新头部坐标是否超出游戏区域边界;自身碰撞则遍历除尾部外的所有蛇身节点。这里有一个关键细节:如果蛇没有吃到食物,尾部会移动走,所以新头部撞到当前尾部其实是安全的。严谨的做法是遍历时跳过最后一个节点,否则会出现“贴尾掉头”误判。
bool hitWall = (head.x() < 0 || head.x() >= cols || head.y() < 0 || head.y() >= rows); bool hitSelf = false; for (int i = 0; i < snake.size() - (ateFood ? 0 : 1); ++i) { if (snake[i] == head) { hitSelf = true; break; } }3.3 食物随机生成:避开蛇身的均匀分布算法
食物位置用QRandomGenerator生成,但要排除蛇身占用的格子。简单做法是死循环重试,但极端情况下蛇身占满大半区域时效率会下降。更稳妥的方式是维护一个“空闲格子列表”:
QVector<QPoint> freeCells; for (int x = 0; x < cols; ++x) { for (int y = 0; y < rows; ++y) { QPoint p(x, y); if (!snake.contains(p)) freeCells.append(p); } } if (!freeCells.isEmpty()) { int idx = QRandomGenerator::global()->bounded(freeCells.size()); food = freeCells[idx]; }这段代码在游戏区域是20x20的网格时没有任何性能压力。QRandomGenerator::global()是线程安全的全局随机数发生器,bounded(n)返回[0, n)之间的整数,比手写rand() % n更均匀且避免模运算偏差。玩家吃一个食物最多增加一格长度,20x20的网格理论上限是400格,实际对局基本不会超过200格,所以这个算法到游戏后期依然秒出结果。
3.4 游戏状态管理:运行中、暂停、结束三态切换
贪吃蛇看起来简单,但状态管理不做好,程序跑起来会出现各种诡异行为——比如游戏结束后还能继续按方向键让蛇移动。标准做法是定义一个枚举状态:
enum GameState { Ready, Running, Paused, GameOver }; GameState state;每个键盘事件和定时器tick都先检查当前状态。比如定时器只有在Running状态才推进游戏,Space键在Running时暂停、在Paused时恢复,在Ready时按任意方向键启动游戏。这样整个事件流的逻辑就变成了一张清晰的状态表,而不是散落在每个函数里的零散判断。常见误用是只用布尔变量isRunning表示状态,结果碰到“暂停中按方向键改变方向”这类问题就要补丁摞补丁。建议开始写代码前先把状态流转图画出来,哪怕只是在纸上。
4. 界面与交互实现:QPainter绘图、定时器与键盘事件的配合
4.1 绘制网格、蛇身与食物:QPainter的三种画法
QWidget的paintEvent是绘制的唯一入口。每次游戏逻辑更新后,调用update()请求重绘,Qt会在合适的时机触发paintEvent。贪吃蛇的绘制分三层:背景网格线、蛇身矩形、食物圆形。
void SnakeWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 绘制网格 QColor bgColor(30, 30, 35); painter.fillRect(rect(), bgColor); painter.setPen(QPen(QColor(60, 60, 70), 1)); for (int i = 0; i <= cols; ++i) { int x = i * cellSize; painter.drawLine(x, 0, x, rows * cellSize); } for (int j = 0; j <= rows; ++j) { int y = j * cellSize; painter.drawLine(0, y, cols * cellSize, y); } // 绘制蛇身 for (int i = 0; i < snake.size(); ++i) { QPoint p = snake[i]; QColor color = (i == 0) ? QColor(0, 200, 100) : QColor(0, 160, 80); painter.fillRect(p.x() * cellSize, p.y() * cellSize, cellSize, cellSize, color); } // 绘制食物 QPoint f = food; painter.setBrush(QColor(220, 60, 50)); painter.setPen(Qt::NoPen); painter.drawEllipse(f.x() * cellSize, f.y() * cellSize, cellSize, cellSize); }painter.fillRect和drawEllipse是最常用的两个绘制函数。蛇身头部用亮绿色区分,食物用圆形突出。绘制时直接把逻辑坐标(行列号)乘以cellSize换算成像素坐标,思路清晰。paintEvent里不做任何游戏逻辑计算,只负责画,这是Qt绘制的铁律——否则程序会因为事件循环反复触发导致逻辑和绘制耦合,出问题后极难排查。
参数说明:cellSize在初始化时按窗口尺寸和网格数计算,比如窗口宽600px、网格20列,cellSize = 600 / 20 = 30px,这样窗口缩放时保持比例。Antialiasing只对圆形和斜线有效果,矩形不受影响,但统一开启无伤大雅。
4.2 QTimer驱动游戏节奏:为什么setInterval不能用固定值
timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &SnakeWidget::tick); timer->start(150); // 150毫秒走一格初始间隔150ms,即每秒约6.7格,新手友好。吃到食物后可以每吃5个加速一次:
if (score % 5 == 0 && score > 0) { int nextInterval = qMax(80, timer->interval() - 10); timer->setInterval(nextInterval); }qMax防止间隔低于80ms导致蛇快到无法操作。QTimer的精度在Linux桌面环境足够用,但如果游戏区域渲染开销大,定时器可能因为主线程忙碌而跳动。贪吃蛇这种轻量绘制完全没有这个顾虑。另一个关键点:timer->start()只调用一次,后续通过setInterval调整是实时生效的,不需要重启定时器。
4.3 键盘事件:QKeyEvent与按键方向过滤
在QWidget子类里重写keyPressEvent:
void SnakeWidget::keyPressEvent(QKeyEvent *event) { if (state == Ready) { state = Running; } switch (event->key()) { case Qt::Key_W: case Qt::Key_Up: changeDirection(Up); break; case Qt::Key_S: case Qt::Key_Down: changeDirection(Down); break; case Qt::Key_A: case Qt::Key_Left: changeDirection(Left); break; case Qt::Key_D: case Qt::Key_Right: changeDirection(Right); break; case Qt::Key_Space: if (state == Running) state = Paused; else if (state == Paused) state = Running; break; default: QWidget::keyPressEvent(event); } }changeDirection里做两个过滤:一是禁止180度掉头,比如当前方向是Right时按Left应该忽略;二是游戏结束后方向键不响应。180度掉头的判断是(newDir == Up && dir == Down)这类的四组组合,或者用差值判断:abs(newDir - dir) == 2表示相反方向。注意焦点的坑——如果界面上有按钮,按键事件会被抢走,所以贪吃蛇最好不给子控件焦点,或者在窗口上设置setFocusPolicy(Qt::StrongFocus)。
4.4 窗口布局与游戏区域尺寸:fixed size还是自适应
窗口尺寸可以直接固定,也可以跟随窗口缩放。我推荐先固定,降低复杂度:
setFixedSize(cols * cellSize + 2 * margin, rows * cellSize + margin + statusBarHeight);这样窗口无法被用户拉伸,也就避免了绘制时因窗口大小变化导致网格比例错乱的问题。如果你想要自适应,就需要在resizeEvent里重新计算cellSize,并且所有逻辑坐标到像素坐标的换算都依赖它——这种情况下cellSize必须是double而不是int,否则缩放久了误差累积。新手阶段直接从固定尺寸开始,跑通了再改自适应。
5. Linux下的Qt贪吃蛇避坑手册:五个真实踩坑记录
5.1 方向键导致界面不刷新:焦点问题
现象:程序跑起来后,按WASD没反应,但鼠标点击窗口后再按方向键就正常了。
原因:QWidget默认不持有键盘焦点,尤其是程序启动后焦点可能在别的控件或者根本没被设置。
解决:在构造函数里加一行setFocusPolicy(Qt::StrongFocus),并且确认没有其他子控件拦截焦点。如果游戏区域放在了布局器里且周围有按钮,还要在按钮上设置setFocusPolicy(Qt::NoFocus)。
5.2 蛇移动有残影:update没触发或者paintEvent里有状态
现象:蛇每次移动,旧位置没有擦除,画面像“拖尾巴”。
原因:一是逻辑更新后忘了调用update(),Qt不会在数据改变时自动重绘;二是paintEvent里用了painter.fillRect(rect(), bgColor)但rect()计算范围不含边框区域,导致边框部分残留。
解决:在tick()函数最后加update();背景填充用QRect(0, 0, width(), height())而不是rect()——在Qt 6中rect()应该返回正确的widget矩形,但如果设置了margin偏移,这两者可能不同。经验是背景填充始终用widget物理矩形,游戏网格绘制用自定义的gameRect()。
5.3 定时器越走越快:setInterval与start的重复调用
现象:每次吃到食物,蛇速明显变快,而且快得不正常——不止是加速逻辑预期的幅度。
原因:在tick()里写了timer->start(newInterval),这相当于每次移动都重置定时器,而不是修改间隔。更糟的是如果tick()里调用了start(),等价于每次移动后重新倒计时,实际节拍会越来越密。
解决:初始化时只start()一次,后续加速只调setInterval()。判定方法:在timer回调函数第一行加qDebug() << QTime::currentTime().msecsSinceStartOfDay(),打印相邻两次回调的时间差,如果时间差持续小于设定间隔,就是重复start了。
5.4 按键方向与移动方向不一致:180度掉头没挡住
现象:蛇朝向右边时按左方向键,蛇直接穿回自己身体里,游戏结束。
原因:方向判断只做了合法性检查,没做反向过滤。玩家快速按两次方向键时,可能在同一tick内把方向从Right改成Left再改成Up,蛇在下一tick就向上走,不会穿帮,但如果直接改到相反方向就出问题。
解决:changeDirection里增加方向相反判断:
bool isOpposite(Direction a, Direction b) { return (a == Up && b == Down) || (a == Down && b == Up) || (a == Left && b == Right) || (a == Right && b == Left); }同时注意,Qt的键盘重复事件——长按方向键会持续触发keyPressEvent,但每次tick只处理一次方向变化,这是正确的:方向存起来,tick时取用。
5.5 窗口关闭后进程还在后台:Qt事件循环没有正常退出
现象:关闭游戏窗口,命令行ps aux还能看到snake_game进程。
原因:重写了closeEvent但没调用QApplication::quit(),或者主窗口关联了其他顶层窗口。更常见的情况是,在main函数里创建了多个窗口对象,关闭主窗口不代表应用程序退出。
解决:在main函数里用单窗口模式,QApplication::exec()退出条件默认是最后一个窗口关闭。如果自定义了closeEvent,确保调用event->accept()或QApplication::quit():
void closeEvent(QCloseEvent *event) override { event->accept(); QApplication::quit(); }这条坑在面向Qt Creator调试时尤其隐蔽,因为Creator的调试输出面板会持续保留进程,看不出退没退。
6. 让贪吃蛇真正能玩:积分系统、游戏状态流与一个隐藏技巧
6.1 积分与最高分:用QSettings做本地持久化
积分逻辑本身简单——每次吃到食物加10分。但要做好两件事:分数变化时调用update()刷新界面,以及游戏结束时把最高分写入本地配置。用QSettings读写原生格式,比手写JSON文件省事很多:
QSettings settings("MyCompany", "SnakeGame"); int bestScore = settings.value("bestScore", 0).toInt(); // 游戏结束时 if (score > bestScore) { settings.setValue("bestScore", score); bestScore = score; }QSettings在Linux下默认把配置写进~/.config/MyCompany/SnakeGame.conf。注意organizationName和applicationName的命名要一致,否则读写路径对不上。这一步做完,玩家关掉游戏再打开,最高分还在——这个体验立刻让“小游戏”有了一丝“正式软件”的感觉。
6.2 完整状态流的正确姿势:QStateMachine还是手写枚举
看到这里你可能会想:状态流转要不要引入Qt自带的QStateMachine框架。我的观点是——贪吃蛇这个规模,手写枚举加switch就够了。QStateMachine适合状态多、转移条件复杂的场景,比如工业控制面板有十几个状态且状态之间有守卫条件。贪吃蛇的状态就四个(Ready/Running/Paused/GameOver),每种状态的进入和退出各对应一两行代码,硬套状态机框架反而让代码分散在多个lambda里,可读性下降。
手写枚举的原则就三条:
- 状态字段只有一个,不存在“多个布尔共同决定状态”的混乱局面。
- 状态的切换只发生在明确的入口函数:
startGame()、pauseGame()、resumeGame()、endGame()。 - 任何外部事件(按键、定时器)先查状态,状态不匹配直接return。
6.3 一个提升手感的隐藏技巧:预输入缓冲
默认实现里,如果玩家在两次tick之间快速按了两个方向键,只有最后一个方向会生效。常见场景是:蛇向右移动,玩家想先向下再向左——先按Down再快速按Left,如果第二次按键发生时蛇的方向还是Right,Left会被反向过滤掉,蛇继续向右而不是先向下,玩家会觉得“操作没跟上”。
解法是维护一个方向队列:
QQueue<Direction> pendingDirs; void keyPressEvent(QKeyEvent *event) { // ...解析方向 if (valid) { pendingDirs.enqueue(dir); } } void tick() { // 每tick最多消费一个方向 while (!pendingDirs.isEmpty()) { Direction next = pendingDirs.dequeue(); if (!isOpposite(next, currentDir)) { currentDir = next; break; } } }这样蛇的方向更新滞后一个tick,但玩家连按两个方向键时都能按顺序执行,手感提升非常明显。代价是蛇可能比你预期晚一格转向,但大多数玩家感知不到这个滞后,只感受到“输入不丢”。这个技巧在专业游戏开发里叫输入缓冲,是性能优化之外的另一种优化方向——体验优化。
我最早写贪吃蛇时,没做输入缓冲,拿给同事测试,十个人里八个说“怎么按了没反应”,后来加上这个队列,零解释,所有人都说顺了。这种细节不会出现在任何教程的“必学列表”里,但恰恰是决定一个项目是“能跑”还是“能玩”的分水岭。做完输入缓冲后,建议你再试试把cellSize调小到20px、场地扩到30x30,那个节奏才对得起“进阶”二字。希望这篇能在你下一个Qt项目里省几个晚上的调试时间。
本文还有配套的精品资源,点击获取