☰
Linux下用C++和Qt手搓贪吃蛇:从架构到避坑的完整指南
2026/10/15 10:43:42 网站建设 项目流程

简介:这是一份面向Linux平台、使用C++与Qt框架开发的贪吃蛇小游戏完整源码工程,适合正在学习Qt界面编程、事件处理与小型游戏架构的初学者和进阶开发者参考。项目按模块划分清晰,涵盖界面显示与菜单切换、游戏画面与控制逻辑、等级管理、背景音乐播放与切换、英雄榜分数统计与高分记录更新,以及帮助信息展示等功能,可作为课程设计或练手项目的完整范例。压缩包共71个文件,约17.22MB,包含10个cpp源文件与8个头文件承载核心逻辑,3个ui界面文件与21张png、8张jpg图片负责视觉资源,3个wav音频用于背景音乐,另有qrc资源文件、pro工程文件、makefile及player.db数据库文件,便于直接编译运行与二次修改。目前已有1474人学习下载,读者可从中获取Qt多界面切换、信号槽通信、SQLite分数存储与音频播放等具体实现思路,快速理解一个可运行小游戏的完整目录结构与模块协作方式。

1. 从零手搓一个 Linux 桌面贪吃蛇:为什么我劝你先别急着写游戏循环

很多人第一次在 Linux 上碰 Qt,都是从贪吃蛇这种小游戏开始的。原因很实在:它足够小,小到你能在一周内把窗口、绘图、定时器、键盘事件全串一遍;又足够完整,完整到你能摸到一个桌面程序从启动到退出的全部骨架。这个项目就是干这件事的——用 C++ 和 Qt 在 Linux 下写一个能跑、能玩、能改的贪吃蛇。它适合两类人:一类是刚学完 C++ 语法、想找个东西练手的;另一类是想快速复习 Qt 事件模型和 QPainter 绘图的老手。我见过太多人一上来就写 while 循环加 sleep,结果窗口直接卡死,所以这篇笔记会先把架构和踩坑讲透,再带你跑起来、改起来。

2. 技术选型与工程骨架:为什么是 Qt Widgets 而不是 QML

2.1 选型逻辑:C++ 逻辑 + Qt Widgets 绘图

这个项目最核心的选型就一句话:用 C++ 写游戏逻辑,用 Qt Widgets 做渲染和输入。为什么不用 QML?因为贪吃蛇的逻辑是纯 C++ 的二维数组操作,QML 的声明式语法反而会把简单问题复杂化。为什么不用 SDL 或 OpenGL?因为 Qt 已经帮你把窗口、事件循环、定时器全封装好了,你只需要重写paintEvent和keyPressEvent两个函数就能跑起来。

常见做法是继承QWidget,把整个游戏塞进一个自定义控件里。这样做的另一个好处是:以后想嵌入到更大的 Qt 项目里,直接把这个 Widget 当组件用就行,不用改架构。

2.2 目录结构:一个最小可编译的 Qt 工程长什么样

一个能跑起来的 Qt Widgets 工程,目录其实很干净。我一般会这样组织:

snake-qt/ ├── snake.pro # qmake 工程文件 ├── main.cpp # 程序入口 ├── snakewidget.h # 游戏窗口类声明 ├── snakewidget.cpp # 游戏逻辑与绘图实现 └── README.md

如果你用 CMake,把.pro换成CMakeLists.txt即可,Qt5 和 Qt6 都支持。这里用 qmake 是因为它在 Linux 下最省事,qmake && make两条命令就能出可执行文件。

.pro文件里关键就三行:

QT += core gui widgets TARGET = snake SOURCES += main.cpp snakewidget.cpp HEADERS += snakewidget.h

QT += widgets是必须的,Qt5 之后 Widgets 模块和 Core 分开了,漏写会报找不到QApplication。TARGET是生成的可执行文件名,Linux 下没有后缀,直接./snake就能跑。

2.3 核心类设计:一个 QWidget 扛下所有

整个游戏的核心就是SnakeWidget这一个类。它的职责边界很清晰:维护蛇身坐标、食物坐标、当前方向、游戏状态,然后在paintEvent里把这些数据画出来。头文件大概长这样:

// snakewidget.h #ifndef SNAKEWIDGET_H #define SNAKEWIDGET_H #include <QWidget> #include <QVector> #include <QPoint> #include <QTimer> #include <QKeyEvent> #include <QPainter> 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(); // 定时器回调,驱动游戏前进 private: void initGame(); // 重置游戏状态 void spawnFood(); // 随机生成食物 bool checkCollision(); // 碰撞检测 QVector<QPoint> snake; // 蛇身,snake[0] 是蛇头 QPoint food; // 食物坐标 int direction; // 0上 1下 2左 3右 int cellSize; // 每个格子的像素大小 int cols, rows; // 网格列数和行数 bool gameOver; // 游戏结束标志 QTimer *timer; // 游戏主定时器 }; #endif

这里有几个设计点值得说。第一,蛇身用QVector<QPoint>存,QPoint的 x 和 y 直接对应网格坐标,不用自己写结构体。第二,方向用int而不是枚举,是为了后面做“禁止 180 度掉头”时判断更直接。第三,QTimer用指针成员,方便在游戏结束时stop(),重新开始时start()。

注意:Q_OBJECT宏不能省,否则信号槽和slots关键字都不生效,编译会报undefined reference to vtable。

3. 核心模块拆解:定时器、绘图与碰撞检测的完整链路

3.1 游戏主循环:为什么用 QTimer 而不是 while

这是新手翻车最多的地方。很多人写游戏循环的第一反应是:

while (true) { update(); sleep(200); }

在 Qt 里这么写,窗口会直接卡死,因为主线程被死循环占住了,事件循环根本没机会处理重绘和输入。正确的做法是把“循环”交给QTimer:

// snakewidget.cpp 构造函数片段 SnakeWidget::SnakeWidget(QWidget *parent) : QWidget(parent) { cellSize = 20; cols = 30; rows = 20; setFixedSize(cols * cellSize, rows * cellSize); setFocusPolicy(Qt::StrongFocus); // 必须设置,否则收不到键盘事件 timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &SnakeWidget::tick); timer->start(150); // 150ms 走一格,数值越小蛇越快 initGame(); }

QTimer::timeout每隔 150 毫秒触发一次tick(),tick()里只做逻辑更新,然后调用update()请求重绘。update()不会立刻重绘,而是把重绘请求丢进事件队列,等主线程空闲时统一处理。这样窗口既不会卡,输入也不会丢。

setFocusPolicy(Qt::StrongFocus)这行是血泪经验。不写的话,Widget 默认不接收键盘焦点,你按方向键一点反应都没有,排查半天以为是keyPressEvent没重写对。

3.2 tick 函数:一帧里到底发生了什么

tick()是整个游戏的心脏,它按顺序做四件事:算新蛇头、判碰撞、吃食物、更新蛇身。

void SnakeWidget::tick() { if (gameOver) return; // 1. 根据当前方向计算新蛇头坐标 QPoint head = snake.first(); switch (direction) { case 0: head.ry() -= 1; break; // 上 case 1: head.ry() += 1; break; // 下 case 2: head.rx() -= 1; break; // 左 case 3: head.rx() += 1; break; // 右 } // 2. 撞墙检测:超出网格范围直接结束 if (head.x() < 0 || head.x() >= cols || head.y() < 0 || head.y() >= rows) { gameOver = true; timer->stop(); update(); return; } // 3. 撞自己检测:新蛇头不能和现有蛇身重合 if (snake.contains(head)) { gameOver = true; timer->stop(); update(); return; } // 4. 插入新蛇头 snake.prepend(head); // 5. 判断是否吃到食物 if (head == food) { spawnFood(); // 吃到就不删尾巴,蛇变长 } else { snake.removeLast(); // 没吃到就删尾巴,蛇保持长度 } update(); // 请求重绘 }

这里的关键逻辑是“先加头、再决定删不删尾”。吃到食物时只加头不删尾,蛇自然长一格;没吃到时加头又删尾,蛇看起来就在移动。snake.contains(head)用的是QVector的线性查找,蛇短的时候完全够用,蛇长到几百节也不会卡,因为每帧只查一次。

spawnFood()的实现要注意别把食物生成到蛇身上:

void SnakeWidget::spawnFood() { do { food.setX(QRandomGenerator::global()->bounded(cols)); food.setY(QRandomGenerator::global()->bounded(rows)); } while (snake.contains(food)); }

QRandomGenerator是 Qt5.10 之后推荐的随机数类,比qrand()更安全也更方便。bounded(cols)返回[0, cols)的随机整数,正好对应网格下标。

3.3 paintEvent:用 QPainter 把数据画成画面

绘图部分全部集中在paintEvent里,逻辑和渲染彻底分离。这样以后想换皮肤、加网格线、加分数显示,都只改这一个函数。

void SnakeWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); // 画背景 painter.fillRect(rect(), QColor(30, 30, 30)); // 画食物(红色圆角矩形) painter.setBrush(QColor(220, 60, 60)); painter.setPen(Qt::NoPen); painter.drawRoundedRect(food.x() * cellSize, food.y() * cellSize, cellSize - 2, cellSize - 2, 4, 4); // 画蛇身,蛇头用亮绿色区分 for (int i = 0; i < snake.size(); ++i) { QColor color = (i == 0) ? QColor(80, 220, 120) : QColor(60, 160, 90); painter.setBrush(color); painter.drawRoundedRect(snake[i].x() * cellSize, snake[i].y() * cellSize, cellSize - 2, cellSize - 2, 3, 3); } // 游戏结束提示 if (gameOver) { painter.setPen(Qt::white); painter.setFont(QFont("Sans", 24, QFont::Bold)); painter.drawText(rect(), Qt::AlignCenter, "Game Over\n按 R 重开"); } }

cellSize - 2是为了在格子之间留 2 像素缝隙,视觉上蛇身是一节一节的,不会糊成一条。drawRoundedRect的圆角半径给 3 到 4 像素,看起来更柔和。Q_UNUSED(event)是告诉编译器这个参数我不用,避免警告。

3.4 keyPressEvent:方向输入与防 180 度掉头

键盘处理看着简单,但有个经典 bug:蛇正在向右走,你快速按了上再按左,如果两帧之间方向被连续改两次,蛇可能直接反向撞死自己。解决办法是记录“当前方向”和“待处理方向”,在tick()里才真正应用。

void SnakeWidget::keyPressEvent(QKeyEvent *event) { switch (event->key()) { case Qt::Key_Up: if (direction != 1) direction = 0; // 不能从下直接转上 break; case Qt::Key_Down: if (direction != 0) direction = 1; break; case Qt::Key_Left: if (direction != 3) direction = 2; break; case Qt::Key_Right: if (direction != 2) direction = 3; break; case Qt::Key_R: if (gameOver) initGame(); // 游戏结束后按 R 重开 break; default: QWidget::keyPressEvent(event); } }

direction != 1这种判断就是防掉头:当前向上(0)时,按向下(1)会被忽略。initGame()里记得把gameOver置回false,并重新timer->start(150),否则重开后蛇不动。

4. 编译运行与避坑:Linux 下从 qmake 到可执行文件

4.1 环境准备与编译命令

在主流 Linux 发行版上,装 Qt 开发包最省事的方式是用包管理器。以 Debian/Ubuntu 系为例:

sudo apt update sudo apt install qtbase5-dev qt5-qmake g++ make

装完之后,进入工程目录,两条命令编译:

qmake snake.pro make -j4

-j4是并行编译,核多可以写-j8。编译成功后当前目录会出现snake可执行文件,直接./snake运行。如果报cannot find -lQt5Widgets,说明qtbase5-dev没装全,重新执行上面的安装命令。

4.2 避坑清单:五个我真实踩过的坑

现象一:窗口能弹出,但按方向键没反应。原因:Widget 没有获取键盘焦点。 解决:构造函数里加setFocusPolicy(Qt::StrongFocus),或者在show()之后调用setFocus()。

现象二:编译报undefined reference to vtable for SnakeWidget。原因:类里写了Q_OBJECT宏,但没有经过 moc 预处理。 解决:确认.pro里HEADERS += snakewidget.h写了,然后重新qmake && make。moc 是根据头文件生成中间代码的,头文件没进工程就会漏。

现象三:蛇移动速度忽快忽慢,或者按一下方向键蛇连走好几格。原因:QTimer间隔太短,或者键盘事件里直接改了蛇身坐标。 解决:把timer->start(150)的数值调大,同时确保keyPressEvent只改direction,不动snake。所有位置更新只在tick()里做。

现象四:食物生成在蛇身上,蛇一吃就“穿模”。原因:spawnFood()没有排除蛇身坐标。 解决:用do...while循环,直到随机出的坐标不在snake里为止。蛇很长时这个循环可能多跑几次,但网格有 600 个格子,蛇再长也远没占满,不会死循环。

现象五:游戏结束后按 R 没反应。原因:initGame()里忘了重置gameOver,或者忘了重启定时器。 解决:initGame()里把gameOver = false、snake清空并重新填入初始三节、direction重置为右、timer->start(150)全部写全。

提示:如果运行时提示qt.qpa.plugin: Could not load the Qt platform plugin "xcb",说明系统缺少 X11 相关库,装libxcb-xinerama0之类的依赖即可,这是 Qt 在 Linux 上的常见环境问题,跟代码无关。

5. 二次开发改造:从能玩到好玩,我一般会动哪几刀

5.1 加分数与最高分记录

最值得先加的肯定是分数。改动很小:在SnakeWidget里加一个int score和int highScore,吃到食物时score += 10,游戏结束时更新最高分。最高分可以用QSettings持久化到本地,下次启动还能读回来。

// 吃到食物时 if (head == food) { score += 10; if (score > highScore) highScore = score; spawnFood(); } // 游戏结束时保存 QSettings settings("MyStudio", "SnakeQt"); settings.setValue("highScore", highScore); // 启动时读取 highScore = settings.value("highScore", 0).toInt();

QSettings在 Linux 下默认写到~/.config/MyStudio/SnakeQt.conf,不用自己管文件路径。然后在paintEvent里用drawText把分数画到左上角就行。

5.2 难度分级与速度曲线

固定 150 毫秒太单调。我一般会做一个难度选择:简单 200ms、普通 150ms、困难 100ms。更进阶一点的做法是动态加速——每吃 5 个食物,定时器间隔减 10ms,最低不低于 60ms。改法就是在tick()的吃食物分支里判断:

if (head == food) { score += 10; eatenCount++; if (eatenCount % 5 == 0 && timer->interval() > 60) { timer->setInterval(timer->interval() - 10); } spawnFood(); }

QTimer::setInterval会立即生效,不需要 stop 再 start。这样蛇会越跑越快,紧张感自然就上来了。

5.3 用 QPainterPath 画更顺滑的蛇身

现在的蛇是一节节独立方块,想让它看起来像一条连续的蛇,可以用QPainterPath把蛇身各节中心点连成折线,再用粗画笔描边。核心思路是:遍历snake,把每个格子的中心坐标(x * cellSize + cellSize/2, y * cellSize + cellSize/2)依次lineTo,最后painter.strokePath()。画笔宽度设成cellSize - 4,端点样式设Qt::RoundCap,出来的就是一条圆头圆脑的蛇。这个改动只动paintEvent,逻辑层完全不用碰,正好验证了前面“逻辑与渲染分离”的设计价值。

5.4 改造后的验证方法

每次改完,我习惯按这个顺序过一遍:先编译看有没有警告,再跑起来看蛇能不能正常移动、吃食物、撞墙结束、按 R 重开。加分数后重点验证最高分能不能跨进程保存——关掉程序再开,分数还在才算过。加速度曲线要手动玩到吃 10 个食物以上,确认定时器间隔确实在降且没降到负数。画蛇身那步最容易翻车的是坐标算错半格,表现是蛇整体偏移,这时候把cellSize/2的偏移量打印出来对一下就行。

从那以后我每次改这个项目,都强制先跑一遍“移动、吃食、撞墙、重开”四步冒烟测试,再动新功能。这套流程帮我省了不少回头查 bug 的时间。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询