
简介这是一套面向Linux平台Qt初学者的贪吃蛇小游戏完整项目源码围绕C语言与Qt界面开发展开适合课程设计、期末项目、自学练手或作为小型桌面游戏案例参考。压缩包共71个文件约17.22MB包含10个cpp与8个h源文件、3个ui界面文件、qrc资源文件、Makefile与pro工程文件另有png/jpg图像、wav音效、db数据库及player记录文件便于直接编译运行、查看工程组织并进行二次修改。项目按界面显示、游戏画面与控制、等级管理、背景音乐切换、英雄榜分数统计和帮助信息等模块组织读者可从中理解Qt界面切换、资源打包、音效播放及分数记录等实践环节并对照源码梳理各模块之间的调用关系。已有1475人学习下载适合需要一份结构清晰、功能较完整的Linux Qt小游戏案例来拆解实现思路与模块划分。1. 从一块空窗口开始的贪吃蛇这个 Qt 小游戏适合谁拿来练手上周有位同学问我学了 C 但没碰过 GUI想找个周末能跑起来的东西。我给他翻出一个压在角落里的工程基于 Linux 下 Qt 开发的贪吃蛇小游戏C 写的qmake 和 CMake 两套构建都在装完qtbase5-dev敲两行命令就能出窗口。它不靠图片素材全靠QPainter一格一格画代码量小到能一个下午读完却把QWidget自绘、QTimer定时驱动、键盘事件分发、QSettings存盘这几件事全串了一遍。适合两类人一是刚学完 C 语法想摸一摸信号槽和事件循环的二是做过业务代码、想回头看「一个完整游戏循环到底怎么转」的。不适合想找成熟游戏框架的人这工程里没有场景图、没有物理、没有资源管线它就是一块干净的白板。2. 工程骨架与技术选型一个 QWidget 是怎么扛起整个游戏循环的先把这工程的家底摊开。它没有多余的抽象层从main.cpp进去构造一个窗口show()剩下的全在窗口类里。技术栈就四样东西C17、Qt Widgets、QTimer、QSettings。层选型落地说明语言标准C17CONFIG c17用得上auto、结构化绑定GUIQt WidgetsQt5.12 / Qt6 兼容只依赖QtWidgets一个模块不引 QML渲染QPainter 直接画在 QWidget 上无贴图、无场景图纯矩形填充节拍QTimer 单次循环驱动一个 timer 就是游戏的心跳输入keyPressEvent 重写不用 QAction / 快捷键表持久化QSettingsLinux 下落到~/.config/org/app.conf这种「一个 QWidget 打天下」的写法在真实项目里当然不推荐但作为教学骨架极其合适——所有状态都摆在一个类里你读代码的时候不用在十个文件之间跳。2.1 目录结构与两套构建qmake 与 CMake 都要能跑工程目录短得可以背下来snake-qt/ ├── snake.pro # qmake 工程文件 ├── CMakeLists.txt # CMake 工程文件二选一 ├── main.cpp # 入口只做三件事 ├── snakewidget.h # 窗口类声明含 Q_OBJECT ├── snakewidget.cpp # 全部玩法与绘制 └── README.mdmain.cpp短到没什么可讲的但高 DPI 那行必须在QApplication构造之前设否则大屏上格子会糊#include QApplication #include snakewidget.h int main(int argc, char *argv[]) { // Qt5 需要显式打开高 DPI 缩放Qt6 默认开启这行可留可删 QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication app(argc, argv); SnakeWidget w; w.setWindowTitle(QStringLiteral(Snake)); w.show(); return app.exec(); }qmake 版本只需要四行有效声明QT widgets会自动带上core和gui# snake.pro QT widgets CONFIG c17 TARGET snake SOURCES main.cpp snakewidget.cpp HEADERS snakewidget.h习惯 CMake 的人用下面这份重点只有一行CMAKE_AUTOMOC ON——它决定 moc 会不会自动跑漏了就是后面避坑章节第一个坑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 就必须打开 find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(snake main.cpp snakewidget.cpp snakewidget.h) target_link_libraries(snake PRIVATE Qt5::Widgets)Linux 上装依赖这一步不同发行版命令不一样Debian/Ubuntu 系是这样sudo apt install build-essential cmake qtbase5-dev # 想让 .pro 也能编再加 qttools5-dev-tools 拿到 qmake编的时候两套命令分别对应# 路线 Aqmake qmake snake.pro make -j$(nproc) # 路线 BCMake cmake -S . -B build cmake --build build -j$(nproc)-j$(nproc)是拿满 CPU 核数并行编译这个工程小到感知不出来但习惯要养好后面接了别的库就知道差别。2.2 为什么是 QWidget 自绘而不是 QGraphicsScene这是选型上最值得说的一处。Qt 里做 2D 游戏至少有两条路QGraphicsView/QGraphicsScene那套场景图框架或者直接在QWidget上重写paintEvent用QPainter画。选后者的理由很实在。贪吃蛇的画面元素只有两种实心方块和实心方块没有旋转、没有缩放、没有碰撞检测框架需求蛇身格子最多几百个。QGraphicsScene带来的好处图元索引、空间分区、内置碰撞这里一个都用不上反而多出三层对象生命周期要管。用paintEvent的话整个渲染就是一个 for 循环调试的时候断点打进去看到的就是最终画面。代价也有全部靠手写窗口 resize 时你要自己算缩放动画过渡要自己插值元素一多比如上千个图元重绘会吃力。所以这个选择只在这个量级成立——你要是打算把贪吃蛇扩成有粒子特效的版本趁早换场景图。2.3 事件循环与 QTimer游戏节拍从哪来Qt 程序的骨架是app.exec()里那个事件循环所有输入、重绘、定时器都从这一个循环里分发出来。游戏节拍靠QTimer挂进去它到期就往事件队列塞一个timeout信号槽函数跑完再回去等。这里有个容易忽略的点不要用while(true)加sleep写游戏循环。那样会把事件循环堵死窗口直接变成「未响应」键盘事件也进不来。正确姿势是让 Qt 帮你调度你的逻辑只负责「被叫一次走一步」。QTimer有两种用法差一个字行为差很远// 写法一周期触发interval 固定 m_timer new QTimer(this); connect(m_timer, QTimer::timeout, this, SnakeWidget::onTick); m_timer-start(150); // 150ms 一步约 6.7 格/秒 // 写法二单次触发在槽里重新 start —— 适合「速度动态变化」的场景 m_timer-setSingleShot(true);大部分贪吃蛇用写法一就够了。想让难度递增吃得越多跑得越快在吃到食物的时候调一次m_timer-start(newInterval)即可start()会顺带重置计时不会出现两个 timer 并行跑的问题——这一点比setInterval()之后再手动start()安全。connect那行还有一个隐藏依赖SnakeWidget类声明里必须有Q_OBJECT宏否则信号槽机制不生效编译期可能过得去运行期默默不触发。这个宏也是后面 moc 报错的根源。3. 核心玩法拆解一步推进里藏着的四个判定游戏逻辑全压在一个onTick()里。看着简单实际上一步推进要同时处理方向采纳、边界判定、自撞判定、吃食物判定顺序错了就出玄学 bug。3.1 蛇身数据结构QList 够用但要知道它的代价蛇身本质是一个有序的格子序列头在最前尾在最后。每走一步做的事是「头部加一格、尾部删一格」这是典型的双端队列操作。Qt 里最顺手的是QListQPoint// snakewidget.h 关键成员 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 onTick(); private: void resetGame(); void spawnFood(); // 棋盘规格30 列 × 24 行每格 20px窗口正好 600×480 static constexpr int kCols 30; static constexpr int kRows 24; static constexpr int kCell 20; QListQPoint m_body; // 下标 0 是蛇头末尾是蛇尾 QPoint m_food; QPoint m_dir{1, 0}; // 当前生效方向初始向右 QPoint m_pendingDir{1, 0}; // 缓存最近一次按键方向 QTimer *m_timer nullptr; int m_score 0; int m_best 0; bool m_running false; bool m_paused false; };QListQPoint在 Qt6 里就是QVector的别名内存连续prepend和removeLast都是 O(1) 摊销prepend在头部预留了空间。如果换成std::vector头部插入是 O(n)蛇一长每帧都在搬内存。所以这里用QList不是随手选的是踩过一次性能坑之后的选择。但QList::contains()是线性扫描。判断「下一格是不是撞到自己」时每步都要扫一遍蛇身蛇长 300 格就是 300 次比较一秒钟 7 步也就 2000 多次完全无感。所以这个工程没上QSetQPoint做占用表。真到蛇身接近满屏720 格的时候才值得考虑加一个并行的QSet索引。3.2 一步推进的完整链路方向、边界、自撞、进食onTick()是全部逻辑的核心我把它拆成四段看。void SnakeWidget::onTick() { if (!m_running || m_paused) return; // 1) 采纳方向只在每步开始时生效天然挡掉一帧内连按两次的掉头 if (m_pendingDir m_dir ! QPoint(0, 0)) { m_dir m_pendingDir; } const QPoint next m_body.first() m_dir; // 2) 边界判定撞墙结束或者按配置穿墙 if (next.x() 0 || next.x() kCols || next.y() 0 || next.y() kRows) { m_running false; update(); return; } // 3) 自撞判定尾巴这一格本轮会让出来所以不参与判定 QListQPoint bodyWithoutTail m_body; if (m_body.size() 1) bodyWithoutTail.removeLast(); if (bodyWithoutTail.contains(next)) { m_running false; update(); return; } // 4) 前进头进、尾出吃到食物则尾部不删长度 1 m_body.prepend(next); if (next m_food) { m_score 10; spawnFood(); } else { m_body.removeLast(); } update(); // 主动请求重绘否则画面要等别的事件触发才刷 }几个细节值得单独抠出来说。方向采纳那行m_pendingDir m_dir ! QPoint(0,0)是防 180 度掉头的。如果当前向右(1,0)玩家按左(-1,0)两者相加是零向量说明是反向直接丢弃这次输入。这种写法比逐个比较dx -dirx dy -diry简洁也不会漏判。为什么要有m_pendingDir这一层缓冲——这是整个游戏里最容易翻车的地方下一章展开。自撞判定要排除尾巴蛇头去咬尾巴尖那一格是合法的因为尾巴在同一帧里会让出来。如果图省事直接m_body.contains(next)玩家就会发现明明能过的位置莫名其妙死了。尾部删不删只在吃到食物时不同很多新手写成「先删尾再进头吃到食物再把尾巴加回去」那种写法在连续吃两个食物时容易长度算错。prepend之后按条件removeLast是更稳的顺序。3.3 食物生成别在死循环里随机抽格子食物位置的生成看着最没技术含量实际上是最容易写出「蛇长了之后游戏卡死」的地方。#include QRandomGenerator void SnakeWidget::spawnFood() { // 先把所有空格收集起来再从里面随机取一个 QListQPoint freeCells; freeCells.reserve(kCols * kRows - m_body.size()); for (int y 0; y kRows; y) { for (int x 0; x kCols; x) { const QPoint p(x, y); if (!m_body.contains(p)) freeCells.append(p); } } if (freeCells.isEmpty()) { // 一个空格都不剩 蛇填满棋盘当作通关处理 m_running false; return; } const int idx QRandomGenerator::global()-bounded(int(freeCells.size())); m_food freeCells.at(idx); }对比一下常见错误写法do { p randPoint(); } while (m_body.contains(p));。蛇短的时候没问题蛇长到占满 700 格里的 690 格期望抽中次数是 70 次才命中一个空格最坏情况直接死循环。这类 bug 只在游戏玩到很后期才出现测试时基本碰不到属于典型的「上线才炸」型问题。QRandomGenerator::global()是 Qt 5.10 之后引入的线程安全随机源比qrand()好qrand()在 Qt6 里已经移除了——这也是从 Qt5 迁到 Qt6 时会遇到的编译错误之一看到qrand was not declared就换成这个。规则到这里就齐了。剩下的是把状态画出来、把按键接进来也就是下一章的事。4. 渲染与输入paintEvent 里的坐标系和键盘缓冲逻辑跑在onTick()但玩家看到的画面在paintEvent()操作手感在keyPressEvent()。这两块决定了游戏「好不好玩」。4.1 网格坐标系一格 20px坐标换算是所有绘制的起点整个游戏的坐标有两套逻辑上是(x, y)的格子下标物理上是像素。中间靠一个乘法换算简单但必须有统一约定不然到处会出现 ±1 的偏移。void SnakeWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); // 像素格子不要抗锯齿否则边缘发灰 p.fillRect(rect(), QColor(24, 26, 32)); // 食物留 2px 内缩视觉上不和蛇身贴死 p.setPen(Qt::NoPen); p.setBrush(QColor(230, 92, 92)); p.drawRect(QRect(m_food.x() * kCell, m_food.y() * kCell, kCell, kCell) .adjusted(2, 2, -2, -2)); // 蛇身从尾往头画头最后画保证压在最上层 for (int i m_body.size() - 1; i 0; --i) { const QPoint cell m_body.at(i); // 越靠后的节越暗用亮度渐变代替贴图 const int brightness qMax(60, 200 - i * 6); p.setBrush(i 0 ? QColor(120, 220, 160) : QColor(60, brightness, 120)); p.drawRect(QRect(cell.x() * kCell, cell.y() * kCell, kCell, kCell) .adjusted(1, 1, -1, -1)); } // 分数条 p.setPen(QColor(220, 220, 220)); p.setFont(QFont(QStringLiteral(Sans), 11)); p.drawText(QRect(8, 4, width() - 16, 20), Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral(Score %1 Best %2).arg(m_score).arg(m_best)); }几个参数说明。kCell 20决定了单格边长窗口尺寸就是kCols * kCell和kRows * kCell在构造函数里setFixedSize(600, 480)锁死省掉 resize 时重算布局的麻烦。.adjusted(1, 1, -1, -1)是给每个方块留 1px 缝隙蛇身连着的时候能看到节与节之间的分隔视觉上更像蛇而不是一根粗条。setRenderHint(Antialiasing, false)这行是有意关掉的。抗锯齿在斜线、圆角上有用但这里全是轴对齐的矩形开了只会让边缘出现半透明的灰边反而不干净。从尾往头画这个顺序也不是随便定的。QPainter是后画的覆盖先画的如果从头往尾画蛇头会被第二节压住一部分转弯的时候看起来像头缩进去了。4.2 键盘输入与方向缓冲为什么不能直接改 m_dir这是整个工程里我改过最多次的一段。最初版本直接m_dir QPoint(0,1)跑起来就发现一个问题玩家长按方向键或者快速连按两个方向蛇会瞬间掉头咬死自己。原因藏在事件和定时器的时序里。假设蛇向右跑玩家在一帧150ms内先按下、再按左第一下按键m_dir立刻变成(0,-1)蛇本来会在下一次 tick 向上走。第二下按键m_dir变成(-1,0)。此时m_dir和「上一次实际移动的方向」(1,0)正好相反但代码里没人检查下一次 tick 蛇头就往左撞进自己身体。m_pendingDir这个缓冲层就是解决这个的按键只写缓冲区真正采纳方向只在onTick()开头做一次而且采纳时做反向检测。这样一帧内无论按多少次最终生效的只有一个方向且必定不是当前方向的反向。void SnakeWidget::keyPressEvent(QKeyEvent *e) { switch (e-key()) { case Qt::Key_Up: m_pendingDir QPoint(0, -1); break; case Qt::Key_Down: m_pendingDir QPoint(0, 1); break; case Qt::Key_Left: m_pendingDir QPoint(-1, 0); break; case Qt::Key_Right: m_pendingDir QPoint(1, 0); break; case Qt::Key_Space: // 空格暂停/继续 if (m_running) { m_paused !m_paused; update(); } break; case Qt::Key_R: // R 键重开 resetGame(); break; default: QWidget::keyPressEvent(e); // 不认识的键交还给父类 return; } e-accept(); // 标记已处理阻止继续冒泡 }e-accept()这行别省。Qt 的键盘事件会沿着父子控件链往上冒泡不 accept 的话某些窗口管理器下这个按键还会传给别的控件出现「按一次触发两次」的怪现象。还有一个方向键不响应的经典问题窗口没有焦点。QWidget默认能接收键盘事件的前提是它setFocusPolicy(Qt::StrongFocus)并且真的拿到了焦点。构造函数里补一句setFocusPolicy(Qt::StrongFocus);并且setFocus();能省掉一半「按键没反应」的排查时间。4.3 游戏状态机运行、暂停、结束、重开工程里用两个 bool 表示四个状态简单但不是最清晰的写法。m_running和m_paused组合出运行中true/false、暂停true/true、已结束false/false、未开始false/false。状态切换集中在两个函数里void SnakeWidget::resetGame() { m_body.clear(); const int cx kCols / 2, cy kRows / 2; m_body QPoint(cx, cy) QPoint(cx - 1, cy) QPoint(cx - 2, cy); m_dir QPoint(1, 0); m_pendingDir QPoint(1, 0); m_score 0; m_paused false; m_running true; spawnFood(); if (m_timer) m_timer-start(150); update(); }初始蛇身给三节头在中间、身子往左延伸方向向右——这样一开局就有「往前冲」的动势比单节蛇头好看也避免玩家第一帧就必须操作。结束状态的处理值得提一句m_running false之后m_timer并没有 stop。定时器还在跑只是onTick()开头的守卫直接 return。这么写的好处是重开R 键时不用重新 start timer逻辑更简单坏处是空转每秒 7 次什么都不做。这个量级完全无所谓但如果你的游戏结束时要放一段动画就得区分「暂停」和「结束」不能让守卫把动画也拦掉。最高分存盘放在这层比较自然// 结束时更新最高分 if (m_score m_best) { m_best m_score; QSettings settings(QStringLiteral(demo), QStringLiteral(snake)); settings.setValue(QStringLiteral(best), m_best); } // 构造时读回来 QSettings settings(QStringLiteral(demo), QStringLiteral(snake)); m_best settings.value(QStringLiteral(best), 0).toInt();QSettings在 Linux 下会把数据写到~/.config/demo/snake.conf纯文本可以直接 cat 出来看调试很方便。5. 避坑与排查这份工程里最容易翻车的五个位置代码短不代表坑少。下面五条都是真跑起来之后撞过的按「现象 → 原因 → 解决」记录。5.1 编译期moc 与 vtable 的连环坑现象一链接报undefined reference to vtable for SnakeWidget编译明明通过了。原因几乎一定是 moc 没跑。类里写了Q_OBJECT这个宏会声明一批由 moc 工具生成的虚函数metaObject()、qt_metacall()等moc 不生成对应的.moc文件链接器就找不到这些符号于是拿 vtable 说事。qmake 会自动处理因为它扫头文件找Q_OBJECTCMake 不会你必须显式set(CMAKE_AUTOMOC ON)。另一个隐蔽情况是加完Q_OBJECT之后只敲了make没重新跑qmake.pro里对头文件的依赖没更新moc 根本没被排进构建。解决CMake 用户检查CMAKE_AUTOMOCqmake 用户改完.pro或新增含Q_OBJECT的类之后先qmake snake.pro再make。判断标准很简单——构建目录下应该有moc_snakewidget.cpp没有就是没跑。现象二qrand was not declared in this scope同一份代码 Qt5 编得过 Qt6 编不过。原因是从 Qt6 起qrand()/qsrand()被移除官方推荐QRandomGenerator。解决全局换成QRandomGenerator::global()-bounded(n)需要固定种子复现时用QRandomGenerator(seed)构造一个实例。这个改动影响面很小但混着写就容易漏。5.2 运行期画面不动、按键失灵、不出声现象三程序能跑窗口也是黑的但蛇永远不动只有按 R 才闪一下。三个可能按概率从高到低排一是m_timer忘了start()二是connect那行返回 false 而你没检查——旧式SIGNAL(timeout())/SLOT(onTick())字符串里多一个空格、少一个括号编译期完全正常运行期默默不连三是onTick()里忘了最后的update()逻辑其实每一步都在推进只是没人叫重绘。解决connect一律改用新式函数指针语法QTimer::timeout对SnakeWidget::onTick拼错直接编译不过connect之后可以Q_ASSERT(ok)断言返回值onTick()末尾无条件update()。现象四连按方向键蛇莫名其妙原地掉头撞死自己。原因在 4.2 已经说透直接改m_dir会让按键频率绕过游戏节拍一帧内两次输入就能构成 180 度反向。解决加m_pendingDir缓冲只在 tick 开头采纳一次并做反向检测。这条是血泪经验改之前我以为玩家手速不会那么快实际测下来随手一搓方向键就能触发。现象五大屏笔记本上格子边缘发虚或者窗口明显比设定的 600×480 小一圈。原因是 Qt5 默认不启用高 DPI 缩放系统缩放 150% 时 Qt 拿到的还是逻辑像素绘制出来的矩形被系统拉伸边缘就糊了。窗口尺寸显示不一致则是另一个方向的问题你按逻辑像素设 600×480系统按 150% 缩放后物理尺寸是 900×720看起来「变大了」但如果某处按物理像素算了格子数就会对不上。解决QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)放在QApplication构造之前绘制时统一用逻辑坐标让 Qt 自己处理缩放比不要手动乘devicePixelRatio()——除非你在做像素级精确的位图绘制。5.3 部署与存档QSettings 落到哪去了现象六游戏里打出的最高分关掉再开就没了或者sudo跑一次之后普通用户跑就读不到存档。原因有两层。一是QSettings的路径由organizationName和applicationName决定Linux 下是~/.config/org/app.conf。如果你只写了其中一半比如只setApplicationName没setOrganizationName路径会退化成一个意料之外的位置下次启动读的和上次写的不是同一个文件。二是用sudo ./snake跑的时候进程的 HOME 是 root 的~/.config变成/root/.config普通用户当然读不到。解决在main.cpp里显式app.setOrganizationName(demo); app.setApplicationName(snake);两行都别省调试权限问题时strace -e traceopenat ./snake 21 | grep config能直接看出它在读哪个文件永远不要用sudo跑这种带用户配置的 GUI 程序。6. 二次开发改造把规则抽成配置再加一层能验证的改动写到这游戏已经能玩了。但如果你想拿它当二次开发的底子我建议第一件事不是加特效而是把「魔法数字」全部抽出来。现在工程里150节拍、10得分、30/24棋盘尺寸散在各处改一个难度要翻三个文件这是典型的后期维护债。我一般会先立一个GameConfig结构体读写走QSettings// gameconfig.h —— 把所有可调参数集中到一处 struct GameConfig { int cols 30; int rows 24; int cellPx 20; int tickMs 150; // 起步节拍越小越快 int minTickMs 60; // 速度上限别让蛇快到看不清 int speedUpPerFood 4; // 每吃一个节拍减 4ms int foodScore 10; bool wrapWall false; // true 穿墙模式 bool obstacles false; // true 随机生成障碍物 static GameConfig load() { QSettings s(QStringLiteral(demo), QStringLiteral(snake)); GameConfig c; c.tickMs s.value(QStringLiteral(tickMs), c.tickMs).toInt(); c.foodScore s.value(QStringLiteral(foodScore), c.foodScore).toInt(); c.wrapWall s.value(QStringLiteral(wrapWall), c.wrapWall).toBool(); c.obstacles s.value(QStringLiteral(obstacles), c.obstacles).toBool(); return c; } };抽完之后onTick()里那两处改动就变成了局部手术改造点改动位置关键代码工作量速度递增onTick()吃到食物分支tickMs qMax(minTickMs, tickMs - speedUpPerFood)后重新start()3 行穿墙模式边界判定处出界时改用取模回绕(x cols) % cols5 行障碍物新增m_blocks参与碰撞和食物生成spawnFood()的 freeCells 过滤里加!m_blocks.contains(p)15 行关卡配置GameConfig 一个 JSON 读取函数QJsonDocument::fromJson读外部配置文件40 行穿墙那段值得贴一下因为取模这一步有负数陷阱// 穿墙模式把出界的坐标回绕到另一侧 // 注意 (x % n n) % nC 的 % 对负数结果是负的 if (cfg.wrapWall) { const int nx (next.x() % kCols kCols) % kCols; const int ny (next.y() % kRows kRows) % kRows; next QPoint(nx, ny); }-1 % 30在 C 里等于-1不是29。直接拿去做下标就是越界访问运气好崩溃运气不好读到别的内存还画出来了——这个坑我在别的格点项目里翻过车所以每次写回绕都强制套两层。第二件事把逻辑从 UI 里剥出来。现在onTick()直接改了成员变量、直接update()想写测试根本没法下手。我会把「走一步」抽成一个纯函数// gamelogic.h —— 不依赖任何 UI方便单元测试 struct StepResult { bool alive true; bool ateFood false; QListQPoint body; }; StepResult stepOnce(const QListQPoint body, QPoint dir, const QPoint food, int cols, int rows, bool wrap);抽出来之后用QTest就能覆盖住那几条最要命的规则蛇头咬尾合法、反向输入被丢弃、满屏时判定通关、穿墙坐标回绕正确。我通常会给这几条各写一个QVERIFY跑一次测试比手动玩十把靠谱得多。这也回答了很多人的疑问——「一个玩具项目值不值得写测试」值不值得看的是你后面还要不要改它只要打算改抽一层纯逻辑就是最划算的投资。最后一个技巧调试渲染问题时优先截图而不是加qDebug()。update()之后用QWidget::grab()拿QPixmap存成 PNG坐标换算错没错一眼就能看出来比在paintEvent里打十几行日志高效得多。从那以后我每次开新的 GUI 小项目都强制先把「逻辑层」和「绘制层」拆开、把可调参数塞进一个配置结构体再动手写第一个方块——被那种「改个参数要翻五个文件、改完还不敢确定没改坏」的工程折磨过太多次了。希望帮到你。本文还有配套的精品资源点击获取