简介:面向C++与Qt初学者的迷你贪吃蛇游戏工程,演示如何基于QGraphicsView、QGraphicsScene构建可运行的2D贪吃蛇界面。项目将蛇头、蛇身、食物和边界分别设计为独立图形对象类,完整涵盖定时器驱动的移动循环、方向键控制、碰撞检测、吃到食物后增长与重新生成、计分刷新等核心机制,并在碰撞后停止游戏循环、展示结束界面,适合自学说Qt图形视图框架、事件处理与面向对象封装。压缩包内共8个文件,包括2个cpp源文件、1个头文件、1个ui界面文件、1个pro工程文件、2张jpg图片以及1个user配置记录,整体约209KB,结构紧凑、目录清晰,便于直接编译研读。该资源已有263人学习/下载。通过阅读源码,可掌握自定义QGraphicsItem的绘制方法、利用信号槽关联用户输入与界面更新、用QTimer控制移动速度等关键技巧,是C++开发者从控制台转向图形界面编程的一份实用入门参考。
1. 基于QT的简略版贪吃蛇:一个连源码都能看懂的入门项目
如果你刚接触 Qt 和 C++,正找一个小项目练手,我强烈建议从贪吃蛇开始。这个基于 QT 的简略版贪吃蛇游戏,代码量不大,却把 Qt 最核心的几块东西全摸了一遍:QTimer 定时器、QPainter 绘图、键盘事件、信号槽、坐标与碰撞检测。整个项目没有第三方库,只有四个普通代码文件,编译出来就是一个可以玩的桌面小游戏。对新手来说,它能帮你把「面向对象 + GUI 事件驱动」从抽象概念落到实打实的代码上;对熟手来说,它又是一个干净到能一眼看透的 Qt Widgets 项目范本,拿来改造成完整游戏框架也很快。
2. 项目结构和编译工程:Qt Widgets + C++ 怎么组织
2.1 技术选型:为什么用 Qt Widgets 而不是 QML
这个项目面向的是「想搞懂 C++ 和 Qt 底层机制」的开发者,所以选用了 Qt Widgets 模块,而不是 QML/Qt Quick。Qt Widgets 是一套基于 QWidget 的传统控件框架,所有界面元素都由 C++ 代码直接创建和绘制,没有额外的声明式语言和 JavaScript 运行时。贪吃蛇本身不需要复杂动画和触摸交互,用 Widgets 写反而更直白——你能看到窗口怎么创建、定时器怎么触发、每次重绘都发生了什么。
QML 当然也能做,而且很适合做华丽的界面,但它的逻辑大多写在 JavaScript 里,C++ 只负责暴露接口,对刚学 C++ 的人不太友好。Widgets 模式下,游戏的所有状态(蛇身、方向、食物坐标)都直接作为类的成员变量存在,绘制函数里用 QPainter 一笔一笔画出来,调试起来非常直观。
2.2 文件清单与职责:main.cpp / snakegame.h / snakegame.cpp / snake.pro
这个简略版一共四个文件,每个文件的职责非常单一:
snakegame.h:定义SnakeGame类,继承自QWidget,声明成员变量和槽函数。snakegame.cpp:实现游戏全部逻辑,包括初始化、定时器处理、键盘响应、绘图。main.cpp:创建QApplication和SnakeGame窗口,进入事件循环。snake.pro:Qt 的工程文件,告诉 qmake 项目里有哪些源文件、链接哪些模块。
SnakeGame类里最核心的成员变量是这些:
// snakegame.h 片段 class SnakeGame : public QWidget { Q_OBJECT public: SnakeGame(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private slots: void gameTick(); private: void initGame(); void spawnFood(); bool isCollision(); void updateTitle(); QVector<QPoint> m_snake; // 蛇身,索引0是头 QPoint m_food; // 食物坐标 int m_direction; // 当前移动方向 int m_nextDirection; // 待生效方向(防止一帧内反向) int m_score; // 得分 int m_gridWidth; // 网格列数 int m_gridHeight; // 网格行数 int m_cellSize; // 每个格子的像素边长 QTimer *m_timer; // 游戏心跳定时器 bool m_gameOver; // 游戏结束标记 };看到Q_OBJECT宏就应该知道,这个类要使用 Qt 的信号槽机制,所以snakegame.h在编译前必须先经过moc元对象编译器处理。这也是为什么用 Qt 写项目必须交给我们 Qt 提供的qmake或cmake构建系统,而不是自己拿 g++ 直接编——这一点在后面编译时会坑到不少人。
m_snake用QVector<QPoint>存储,QPoint是 Qt 自带的二维坐标类,存整型坐标刚刚好。索引 0 固定是蛇头,后续索引依次是蛇身。m_direction是当前实际移动方向,m_nextDirection是先记下来的「下次移动方向」,这样用户在快节奏操作时不至于丢失按键。
2.3 .pro 工程文件与信号槽连接
snake.pro这一行行内容都很常规:
QT += core gui widgets greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = snake TEMPLATE = app SOURCES += main.cpp \ snakegame.cpp HEADERS += snakegame.h第一行声明依赖core和gui模块。core提供信号槽、事件循环、容器类,gui提供窗口和绘图基础类。widgets模块在 Qt5 之后被单独拆了出来,因为命令行程序不需要 widgets,所以要么用QT += widgets,要么写成上面对 Qt4 做兼容的写法。TARGET = snake决定最终可执行文件叫snake,如果你在 Windows 上还会看到snake.exe。
信号槽在这个项目里最典型的使用场景是定时器。在构造函数里:
m_timer = new QTimer(this); m_timer->setInterval(100); connect(m_timer, &QTimer::timeout, this, &SnakeGame::gameTick);QTimer每 100 毫秒发一次timeout信号,SnakeGame::gameTick槽函数被调用,相当于游戏的一次「心跳」。这种设计把「时间驱动的逻辑」封装成了事件,不用自己写 while 死循环,窗口界面也不会卡死。setInterval(100)里的数字就是速度,100 毫秒一帧,也就是每秒移动 10 格,后面想加速可以直接改这个参数或者做成动态调速。
3. 核心模块拆解:蛇移动、食物刷新、碰撞检测的完整实现
3.1 游戏数据模型:用 QVector 存蛇身
贪吃蛇的数据模型是整个程序的地基,设计得好,后面所有逻辑都好写。我这里用的是QVector<QPoint>,每个QPoint表示蛇身体的一个格子坐标。比如坐标(5, 3)表示第 5 列第 3 行,对应屏幕上的像素位置是(5 * cellSize, 3 * cellSize)。
初始化游戏时:
void SnakeGame::initGame() { m_snake.clear(); m_snake.append(QPoint(m_gridWidth / 2, m_gridHeight / 2)); m_snake.append(QPoint(m_gridWidth / 2 - 1, m_gridHeight / 2)); m_snake.append(QPoint(m_gridWidth / 2 - 2, m_gridHeight / 2)); m_direction = Qt::Key_Right; m_nextDirection = m_direction; m_score = 0; m_gameOver = false; spawnFood(); updateTitle(); m_timer->start(); }初始蛇身有三节,水平排列在场地中央,头在最右侧,初始方向向右。注意QPoint的 x 是列坐标,y 是行坐标,绘制时对应横向和纵向,别搞反了。m_direction用了Qt::Key_Right这些枚举值,好处是键盘事件里直接拿按键值和它比较就行,少做一层转换。
QVector的头部追加和尾部删除正好模拟蛇的移动逻辑。下一帧要移动时,根据方向计算新头坐标,插入到vector最前面,再把尾节点弹出。如果吃到食物,尾巴就不用弹,蛇身长度自然加一。这个操作是贪吃蛇和普通几何体移动最大的区别:蛇不是整条平移,而是「头进尾出」,像一条真正的链条。
3.2 游戏循环:QTimer 控制刷新节奏
gameTick是每一帧要执行的完整逻辑,按顺序做五件事:更新方向、计算新头、判断碰撞、处理食物、重绘窗口。
void SnakeGame::gameTick() { if (m_gameOver) return; // 防反向:不能直接往当前方向的相反方向走 if (!isOpposite(m_nextDirection, m_direction)) { m_direction = m_nextDirection; } QPoint head = m_snake.first(); QPoint newHead = head; switch (m_direction) { case Qt::Key_Up: newHead.ry() -= 1; break; case Qt::Key_Down: newHead.ry() += 1; break; case Qt::Key_Left: newHead.rx() -= 1; break; case Qt::Key_Right: newHead.rx() += 1; break; default: return; } // 撞墙 if (newHead.x() < 0 || newHead.x() >= m_gridWidth || newHead.y() < 0 || newHead.y() >= m_gridHeight) { m_gameOver = true; updateTitle(); update(); return; } // 撞自己 if (m_snake.contains(newHead)) { m_gameOver = true; updateTitle(); update(); return; } m_snake.prepend(newHead); // 吃到食物 if (newHead == m_food) { m_score += 10; updateTitle(); spawnFood(); } else { m_snake.pop_back(); } update(); // 请求重绘 }isOpposite是一个工具函数,判断两个按键方向是否相反。比如当前方向是右,用户快速按了左,这时候如果你直接更新方向,蛇头会一头扎进自己身体里。我这里的做法是:先让用户按的方向进m_nextDirection,但在gameTick里只有当它不是当前方向的相反方向时才生效,其余按键被丢弃。这个细节很多简略版贪吃蛇不会处理,谁玩谁骂。
QPoint::rx()和ry()返回坐标的引用,可以用+=直接修改,比重新赋值快也简洁。m_snake.contains(newHead)是QVector自带的线性查找,因为蛇身最多几十节,性能完全没问题。
撞墙和撞自己都直接置m_gameOver = true,然后调用updateTitle()把窗口标题改为「Game Over」,再update()触发重绘,让画面停在被撞的那一刻。定时器还在跑,但gameTick开头判断了gameOver就直接返回,等于游戏冻结。
3.3 食物生成与碰撞检测细节
食物坐标必须在游戏场地内,并且不能落在蛇身上。spawnFood的常规写法是用随机数试:
void SnakeGame::spawnFood() { bool valid = false; int attempts = 0; while (!valid && attempts < 10000) { int x = QRandomGenerator::global()->bounded(m_gridWidth); int y = QRandomGenerator::global()->bounded(m_gridHeight); QPoint candidate(x, y); if (!m_snake.contains(candidate)) { m_food = candidate; valid = true; break; } attempts++; } // 极端情况:蛇几乎占满全屏,随机很难命中空格 // 此时应该遍历找第一个空格,而不是死循环 if (!valid) { for (int i = 0; i < m_gridWidth; ++i) { for (int j = 0; j < m_gridHeight; ++j) { QPoint p(i, j); if (!m_snake.contains(p)) { m_food = p; return; } } } } }QRandomGenerator::global()->bounded(n)是 Qt5.10 之后推荐用的随机数接口,返回 0 到 n-1 的整数,代替了老的qrand() % n。我加了attempts < 10000的上限,防止理论上蛇占满 99% 区域时随机数长时间试不中。如果 10000 次都没成功,直接顺序遍历找一个空格,保证游戏能继续。
碰撞检测分两类:边界检测和身体自碰撞。边界检测就在gameTick里,用newHead的坐标直接跟m_gridWidth和m_gridHeight比大小,小于 0 或大于等于边界都算撞墙。身体自碰撞注意一个细节:先判断m_snake.contains(newHead)再prepend,因为蛇在移动前身体的集合就是当前m_snake,如果新头会落到旧尾巴的位置,正常情况下先弹出尾巴、再插入头部,其实不算碰撞。但简略版通常保守处理,把这种情况也算撞死——毕竟蛇头永远不该钻到尾巴占的那一格。如果你希望更贴近真实规则,可以先pop_back再判断,但那样容易出现蛇头瞬间和尾巴重叠的渲染问题,我建议就按保守版来。
3.4 绘制与 UI 刷新:QPainter 画布实现
绘制放在paintEvent里,所有看得见的东西都是手动画出来的:
void SnakeGame::paintEvent(QPaintEvent *) { QPainter painter(this); // 背景 painter.fillRect(rect(), QColor(30, 30, 30)); // 网格线(可选) painter.setPen(QColor(60, 60, 60)); for (int i = 0; i <= m_gridWidth; ++i) { int x = i * m_cellSize; painter.drawLine(x, 0, x, m_gridHeight * m_cellSize); } for (int j = 0; j <= m_gridHeight; ++j) { int y = j * m_cellSize; painter.drawLine(0, y, m_gridWidth * m_cellSize, y); } // 食物 painter.setPen(Qt::NoPen); painter.setBrush(QColor(220, 40, 40)); QRect foodRect(m_food.x() * m_cellSize + 2, m_food.y() * m_cellSize + 2, m_cellSize - 4, m_cellSize - 4); painter.drawEllipse(foodRect); // 蛇 for (int i = 0; i < m_snake.size(); ++i) { QPoint p = m_snake.at(i); QRect bodyRect(p.x() * m_cellSize + 1, p.y() * m_cellSize + 1, m_cellSize - 2, m_cellSize - 2); painter.setBrush(i == 0 ? QColor(30, 180, 60) : QColor(70, 200, 100)); painter.drawRect(bodyRect); } // 游戏结束文字 if (m_gameOver) { painter.setPen(Qt::white); painter.setFont(QFont("Microsoft YaHei", 16, QFont::Bold)); painter.drawText(rect(), Qt::AlignCenter, "Game Over"); } }这里有几个值得注意的细节:画蛇时给每个格子留了 1 像素空隙,蛇看起来是一段段独立方块;画食物用drawEllipse并留 2 像素边距,让食物更接近圆形;游戏结束时用drawText居中显示 Game Over,而没有加额外控件,因为简略版不想再引入按钮和布局。
窗口尺寸和网格参数在构造函数里设定,我固定为 20x20 格子,每格 25 像素,窗口大小为20 * 25 = 500像素。如果以后想改场地大小,只要改两个常量。update()每次触发paintEvent,Qt 会自动把旧的画面擦掉,所以我们不用手动清屏。
键盘事件keyPressEvent负责接收方向键:
void SnakeGame::keyPressEvent(QKeyEvent *event) { int key = event->key(); if ((key == Qt::Key_Up || key == Qt::Key_Down || key == Qt::Key_Left || key == Qt::Key_Right) && !m_gameOver) { m_nextDirection = key; } else if (key == Qt::Key_Space) { // 空格重置游戏 initGame(); } }这里只更新m_nextDirection,真正的方向变化发生在下一个gameTick。这个设计保证了无论用户按键多快,一个时间片内蛇只移动一格,不会出现一次按下连走两步的“跳帧”手感。
4. 编译运行与常见问题排查:从 qmake 到键盘响应的血泪避坑
4.1 运行步骤:qmake、make、启动项目
把四个文件放到同一个目录,比如snake/,然后在终端里执行:
cd snake qmake snake.pro make -j4 ./snake如果你用的是 Qt Creator,直接打开snake.pro,点左下角的绿色运行按钮就行。qmake会根据.pro生成对应平台的 Makefile,make用它编译出可执行文件。在 Windows 上,Qt Creator 会把整个流程包到 “构建” 按钮里,但底层依然是 qmake + 编译器。
如果你是 Qt6 用户,命令不变。唯一要注意的是greaterThan(QT_MAJOR_VERSION, 4)这一行,在 Qt5/Qt6 下会额外引入widgets模块,所以不要删掉。运行后窗口里默认蛇会向右自动移动,按方向键控制转向,按空格重置游戏。
4.2 避坑记录:方向键失效、乱码、速度叠加、食物卡死
第一条坑:方向键怎么按都没反应。现象是窗口能弹出来,蛇也自己会动,但按上下左右完全无效。原因通常是窗口没有获得键盘焦点。QWidget 默认不聚焦,需要通过setFocusPolicy(Qt::StrongFocus)显式告诉 Qt 这个控件可以接收键盘事件。我当时把这一句写在构造函数里才解决。如果你用的是 QMainWindow,还要确保中心窗口也设置了焦点策略。
第二条坑:源码里写的中文字符串在 Win10 下乱码。现象是标题栏和 Game Over 文字变成一堆“锟斤拷”。原因是 GCC 默认把源码当 UTF-8 处理,但 Windows 控制台或某些旧编译器用了 GBK。解决方法是统一用QString::fromUtf8("游戏结束")包裹字符串,或者把源文件保存为 UTF-8 编码并在.pro里加一句QMAKE_CXXFLAGS += -finput-charset=UTF-8 -fexec-charset=UTF-8。后来我干脆所有界面文案都用英文,省心。
第三条坑:蛇越跑越快,甚至一帧走好几格。现象是刚开始速度正常,玩了一会儿突然飞一样冲墙。原因是在某处代码里重复执行了m_timer->start(),导致同一个 QTimer 被启动了多次。Qt 文档里说start()会使定时器重新计时,但如果你的代码在每次吃到食物时都调用timer->start(100),等于把一个 100 毫秒的定时器不断重置,并不会叠加速度;真正会造成“加速”的是你在gameTick里又写了一个 while 循环或递归调用,或者创建了多个 QTimer。排查方法很简单:在构造函数里只start()一次,后续要加速直接改setInterval,不要多次 start。
第四条坑:食物偶尔生成在墙外或者蛇肚子里。现象是画面上没有红色食物,或者食物和蛇身重叠。原因多半是spawnFood里随机范围写错,比如用了qrand() % m_gridWidth但实际上 Qt6 的qrand已经废弃;或者生成后没有检查是否和蛇身重合。我上面给出的代码同时处理了这两个点:用bounded限定范围,用contains排除蛇身,再加 10000 次尝试的兜底逻辑。如果你自测时发现吃完食物后蛇身没变长,多半是prepend之后又无条件pop_back,把刚加的头又弹掉了,检查条件里的else是否正确。
5. 二次开发:把简略版升级成可扩展的贪吃蛇框架
5.1 加入状态控制:暂停、加速、游戏结束面板
现在的简略版逻辑全堆在gameTick里,想加功能容易越加越乱。我的建议是先把游戏状态枚举化,把多个 if 改成状态机。定义enum GameState { Ready, Running, Paused, GameOver },然后让gameTick根据状态分支:
void SnakeGame::gameTick() { if (m_state != Running) return; // 原逻辑 }键盘事件里增加暂停处理:
if (key == Qt::Key_P || key == Qt::Key_Space) { if (m_state == Running) m_state = Paused; else if (m_state == Paused) m_state = Running; }这样暂停后定时器还在跑,但每帧都直接返回,CPU 占用极低。加速功能就更简单,给QTimer::setInterval传一个变量,每吃 5 个食物减 5 毫秒,下限 50 毫秒。改起来只需要在spawnFood里调用m_timer->setInterval(qMax(50, 100 - m_score / 50 * 5)),这类细节我可以放心写进代码。
5.2 自定义绘制:换肤与障碍物
二次开发最常见的是换皮肤。把paintEvent里的颜色提取成成员变量,或者用一个QMap<QString, QColor>存主题色,这一级改动不用动逻辑代码。如果想加障碍物,就在游戏初始化时固定几个坐标放入QSet<QPoint> m_obstacles,然后在移动和生成食物时把障碍物当成墙处理:新头如果撞到障碍物,等同 Game Over;生成食物时确保不落在障碍物上。
再往深一点,可以考虑把SnakeGame的绘制部分拆出去,单独做一个GameRenderer类,让逻辑和渲染彻底解耦。这样你可以在不改变游戏规则的前提下,把 QPainter 换成 QOpenGLWidget,或者加上平滑动画。不过这些都是后续方向,简略版贪吃蛇最可贵的地方就是小到能让你看到所有事情是如何串起来的。从那以后我每次接手新的 Qt 项目,都会先花一天时间做一个这种规模的迷你版复刻,把事件循环、定时器、绘制这条主线摸熟,再去看项目源码心里就不慌了。希望帮到你。
本文还有配套的精品资源,点击获取