简介:面向Linux平台Qt开发练习者的一份贪吃蛇小游戏完整工程,适合已掌握C++基础、希望借助小型项目熟悉Qt界面与事件机制的初中级学习者。工程把界面与菜单切换、游戏画面与控制、等级选择、背景音乐播放切换、分数统计与高分榜、帮助信息等模块拆分实现,便于对照理解可运行小游戏的模块组织与交互流程。压缩包共71个文件,约17.22MB,包含C++源文件与头文件、ui界面文件、png/jpg图像、wav音频,以及工程配置和数据库相关文件,代码、资源与构建配置相对齐全。已有1475人学习下载,且为免费资源。读者可据此研究Qt信号槽、绘图刷新、资源打包、音频播放和本地记录维护,也可在Linux环境编译运行,并围绕界面布局、游戏逻辑、素材替换与排行榜数据处理做二次修改,适合课程设计参考和自学练手。
1. 一份能跑起来的 C++ Qt Linux 贪吃蛇,比十篇教程都实在
学完 C++ 语法之后最容易卡住的地方,不是指针也不是模板,而是不知道怎样把类、容器、事件、绘制这些零散的零件拼成一个“会动”的程序。这份基于 Linux 下 Qt 开发的贪吃蛇小游戏正好卡在这个位置上:它足够小,一下午能通读完;又足够完整,事件循环、定时器、QPainter 绘制、键盘响应、碰撞判定、状态切换一个不缺。它不负责教你语法,它给你一个所有零件都装好了的参照物——你可以先跑起来,再一处一处拆开改,改坏了大不了重编译。适合刚学完 C++ 基础想找个图形项目练手的人,也适合久不写 Qt、想重新捡起信号槽和重绘机制的老手。下面按环境、主循环、数据结构、排查、调参五段拆开讲,每一步都能照着敲出来。
2. 环境与构建:Linux 下把这份 Qt 工程编译出来
拿到一份 Qt 源码包,第一件事不是读代码,是让它在你机器上编译通过。这一步过不去,后面所有分析都是空谈。我见过太多人卡在find_package报错上,然后怀疑源码有问题,其实九成是环境没对齐。这一章把依赖、构建脚本、首次验证三件事做扎实。
2.1 依赖对齐:Qt5 还是 Qt6,以及为什么坚持 Widgets
这个项目用的是 Qt Widgets 模块,不是 QML。原因很实际:贪吃蛇是一个纯 2D 网格游戏,画面元素只有方块和文字,Widgets 的QWidget+QPainter组合足够,而且键盘事件、定时器、窗口缩放这些都在同一个 C++ 类里闭环,不需要引入 QML 运行时和 JS 引擎。对练手项目来说,少一层技术栈就少一半排错成本。
Qt5 和 Qt6 的 API 在这个项目范围内几乎没有差异,QTimer、QPainter、QVector、QRandomGenerator用法一致。我的建议是:系统仓库里哪个版本的开发包齐全就用哪个,别为了“用新版”去源码编译 Qt,那会把一个下午的项目拖成三天。
# 编译工具链:g++、make、cmake 三件套 sudo apt install build-essential cmake # Qt6 的 Widgets 开发包,注意必须是带 dev 的那个 sudo apt install qt6-base-dev # 如果仓库里只有 Qt5,用这个替代 # sudo apt install qtbase5-dev关键点在包名后缀。很多人装的是运行时库,编译时找不到QWidget头文件,报fatal error: QWidget: No such file or directory。带-dev的包才会把头文件、moc元对象编译器和.so链接符号一起装上。装完之后验一下版本,确认你到底装了哪套:
qmake -v # Qt5 环境通常能直接看到版本 cmake --version # 项目要求 3.16 以上提示:Qt5 和 Qt6 的开发包可以共存,但同一个构建目录里只能认一套。切换版本时把
build/整个删掉重新cmake,不要指望增量配置能自己纠正过来。
2.2 CMakeLists 逐行读:AUTOMOC 是这份工程的命门
这份源码用的是 CMake 而不是.pro文件,这是好事——qmake 正在淡出,CMake 是现在 Qt 官方推荐的构建方式。构建脚本不长,但每一行都有讲究,尤其是AUTOMOC。
cmake_minimum_required(VERSION 3.16) project(snake LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关键:凡是类里写了 Q_OBJECT 宏,就必须让 CMake 自动跑 moc set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) # Qt5 环境改成:find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(snake main.cpp gamewidget.cpp gamewidget.h # 头文件也要列进来,moc 才知道要处理它 ) target_link_libraries(snake PRIVATE Qt6::Widgets) # Qt5 环境改成:target_link_libraries(snake PRIVATE Qt5::Widgets)CMAKE_AUTOMOC ON这一行是整个工程能不能链接成功的关键。Qt 的信号槽不是纯 C++ 语法,类声明里写了Q_OBJECT宏之后,需要moc工具扫描头文件,生成一份moc_xxx.cpp参与编译,信号槽的连接表和元对象信息才存在。没有这一步,链接阶段会报undefined reference to vtable for GameWidget,看起来像虚函数没实现,实际是元对象代码缺失。
add_executable里把.h也列出来,不是多余。AUTOMOC的扫描范围依赖它看到的源文件列表,头文件不在列表里,某些 CMake 版本会漏扫。加上这一行的代价是零,收益是省掉一次莫名其妙的链接错误。
target_link_libraries用PRIVATE而不是默认的公开链接,是因为这是个可执行目标,没有下游依赖者,私有链接语义上更准确,也避免以后拆库时把 Qt 依赖传染出去。
2.3 第一次编译与运行验证
构建过程建议单独建目录,源码目录保持干净,方便你以后git diff看自己改了哪些东西。
mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release cmake --build . -j"$(nproc)" ./snake-DCMAKE_BUILD_TYPE=Release会打开优化,对这个体量的程序帧率影响不大,但养成习惯没坏处:调试期改Debug方便断点,交付前切Release。-j$(nproc)用满所有核并行编译,几秒钟就完事。
程序起来之后,别急着玩,先按这份清单核对一遍,任何一条不对,说明源码被改过或者环境有差异:
| 检查项 | 预期表现 | 不符合时先看哪里 |
|---|---|---|
| 窗口尺寸 | 正方形,约 576×576 像素 | kCols/kRows/kCell三个常量 |
| 初始蛇长 | 3 节,朝右 | resetGame()里的初始化 |
| 首次按键 | 蛇立即改向,无延迟 | setFocusPolicy是否设置 |
| 吃到食物 | 长度 +1,分数 +10 | onTick里 eat 分支 |
| 撞墙 / 撞自己 | 停止移动,画面定格 | 碰撞判定的边界条件 |
如果你在没有图形界面的环境里(比如远程终端)只想验证它能跑起来不崩,可以用离屏平台插件跑一次,程序不会开窗但会正常初始化并进入事件循环,几秒后手动结束:
QT_QPA_PLATFORM=offscreen timeout 3 ./snake && echo "初始化正常"这条命令的价值在于把“环境问题”和“代码问题”分开。离屏能跑通、开窗跑不通,问题就在显示层(缺字体、缺平台插件、Wayland/X11 差异);离屏都跑不通,那是编译期或链接期的问题,回头查 2.2。
3. 主循环落地:QTimer、坐标系与输入缓冲
游戏能不能玩,取决于三件事的配合:什么时候推进一帧、推进到哪个格子、玩家在两次推进之间按了几个键。这一章把这三件事讲透,代码可以直接抄进你的工程。
3.1 为什么不能写 while(true):定时器与事件循环的关系
新手最容易犯的错是在main里或者构造函数里写一个while (true) { move(); draw(); sleep(); }。在 Qt 里这么干,窗口会白屏、无法响应关闭按钮、键盘事件永远收不到——因为QApplication::exec()的事件循环被你的死循环堵死了,所有事件(重绘、按键、定时器)都排在队列里没人处理。
Qt 的正确姿势是把“心跳”交给QTimer,每跳一次处理一步逻辑,然后立刻返回事件循环,让别的消息有机会被处理:
// gamewidget.cpp 构造函数片段 GameWidget::GameWidget(QWidget *parent) : QWidget(parent) { setFixedSize(kCols * kCell, kRows * kCell); // 窗口大小由格子数决定 setFocusPolicy(Qt::StrongFocus); // 不设这行,方向键收不到 setAttribute(Qt::WA_OpaquePaintEvent, true); // 告诉系统不需要擦背景,减少闪烁 connect(&m_timer, &QTimer::timeout, this, &GameWidget::onTick); m_timer.setInterval(150); // 150ms 一跳,约 6.7 格/秒 m_timer.start(); resetGame(); }setInterval(150)决定了基础速度。这个值不是随便给的:低于 80ms 人眼还能分辨单格移动,但操作容错窗口太窄,新手两秒就撞墙;高于 250ms 又慢得像是卡了。150ms 是我试过的、能让第一次玩的人活过十秒的区间中点。后面 4.4 会讲怎么在游戏过程中动态调整它。
Qt::WA_OpaquePaintEvent这一行属于手感优化:默认情况下 Qt 在每次重绘前会先用背景色填充整个区域,对一个每 150ms 全量重绘的游戏来说这是纯浪费,还会让高刷新率显示器上出现轻微闪烁。声明不透明之后,paintEvent里画的每一块像素都得自己负责,不能留空。
onTick()就是心跳回调,它只做逻辑推进,不碰绘制,绘制统一交给update()去触发。逻辑和渲染分离,这是后面所有排错能快速定位的前提。
3.2 网格坐标与像素坐标:两套坐标系千万别混用
游戏内部一切都用网格坐标表示,QPoint(x, y)里 x 是列号、y 是行号,范围分别是[0, kCols)和[0, kRows)。像素坐标只在绘制那一刻由网格坐标换算出来。混用的后果是全盘错位或者边界判定失效。
// 网格坐标 -> 屏幕矩形,QPainter 只认这个 inline QRect GameWidget::cellRect(const QPoint &g) const { return QRect(g.x() * kCell, g.y() * kCell, kCell, kCell); } void GameWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); // 方块对齐像素,关掉抗锯齿更清晰 // 1. 背景网格 p.fillRect(rect(), QColor(24, 26, 30)); p.setPen(QColor(40, 44, 52)); for (int x = 0; x <= kCols; ++x) p.drawLine(x * kCell, 0, x * kCell, kRows * kCell); for (int y = 0; y <= kRows; ++y) p.drawLine(0, y * kCell, kCols * kCell, y * kCell); // 2. 食物:留 3px 内边距,视觉上更像个点而不是块 p.fillRect(cellRect(m_food).adjusted(3, 3, -3, -3), QColor(230, 90, 80)); // 3. 蛇身:蛇头用亮色,身体随索引渐暗 for (int i = 0; i < m_snake.size(); ++i) { const int alpha = qMax(90, 255 - i * 6); p.fillRect(cellRect(m_snake.at(i)).adjusted(1, 1, -1, -1), QColor(80, 200, 140, alpha)); } }setRenderHint(Antialiasing, false)是个容易忽略的点。开着抗锯齿画矩形,边角会出现半透明像素,方块之间看起来像有一道缝,尤其在高分屏上很明显。网格游戏的美术风格本来就是硬边像素感,关掉抗锯齿反而更准。
坐标换算只在这一个函数里出现,其他任何地方都不该写* kCell。这是一条纪律:换算点越少,改单元格尺寸时越不容易漏改。
3.3 方向键缓冲:一帧内连按两次为什么必死
这是整份源码里最值得单独拎出来说的一处设计。假设蛇正在向右移动,玩家在 150ms 内先按上、再按左。如果按键处理里直接修改“当前方向”,第一次变成向上,第二次变成向左——而向左正好和上一帧的向右相反,下一跳蛇头会插进自己的脖子,判定自撞,游戏结束。玩家会觉得这游戏有 bug。
正确做法是引入两个方向变量:m_dir是当前生效方向,m_pendingDir是下一跳要生效的方向。按键只写m_pendingDir,而且校验对象必须是m_dir而不是m_pendingDir:
void GameWidget::keyPressEvent(QKeyEvent *e) { switch (e->key()) { case Qt::Key_Up: if (m_dir.y() != 1) m_pendingDir = QPoint(0, -1); // 正在向上时不能反向 break; case Qt::Key_Down: if (m_dir.y() != -1) m_pendingDir = QPoint(0, 1); break; case Qt::Key_Left: if (m_dir.x() != 1) m_pendingDir = QPoint(-1, 0); break; case Qt::Key_Right: if (m_dir.x() != -1) m_pendingDir = QPoint(1, 0); break; case Qt::Key_Space: m_paused = !m_paused; // 暂停/继续 break; case Qt::Key_R: resetGame(); // 重开 break; default: QWidget::keyPressEvent(e); // 其余键交回基类 return; } update(); }校验m_dir而不是m_pendingDir,是这段代码的全部精华。上文那个“上、左”连按的场景,按到“左”时m_dir还是向右,m_dir.x() != 1为假,这次输入被丢弃,蛇下一跳乖乖向上。玩家顶多觉得“我按左没反应”,而不是“这游戏把我弄死了”。
单槽缓冲的代价是极快连按会吞输入。想要更跟手,可以换成定长队列,但队列长度超过 2 之后又会引入“按键延迟生效”的怪异感。我的经验是:蛇速 150ms 以上用单槽足够,把间隔压到 80ms 以下才需要考虑队列。这是取舍,不是对错。
4. 核心数据与判定:蛇身容器、移动顺序、食物生成
逻辑层的东西不多,但每一处都藏着能让游戏“看起来正常其实有鬼”的细节。这一章拆的是最容易写错的三块。
4.1 蛇身容器选型:QVector、QList 与 std::deque 的真实差别
蛇的操作模式非常固定:头部插入、尾部删除、按索引随机访问(绘制用)、整体包含判断(碰撞用)。这四个操作的权重决定了容器选择。
QVector(Qt6 里QList就是它)底层是连续数组,随机访问 O(1),头部插入 O(n) 要整体搬移。看起来头部插入是劣势,但蛇长撑死几百节,搬移几百个 8 字节的QPoint在现代 CPU 上不到一微秒,一跳才做一次,完全无感。而随机访问在每一帧绘制里被调用几百次,这个才是热点。
std::deque头尾插入都是 O(1),听着更配,但它的随机访问要过一层间接寻址,缓存局部性差,绘制循环里反而更慢。QList在 Qt5 时代是指针数组,元素小于指针大小时有额外分配,对QPoint这种 8 字节类型反而不如QVector。
我的选择是QVector<QPoint>,index 0永远是蛇头。这条约定要写死在注释里,因为后面所有first()/last()的语义都依赖它:
QVector<QPoint> m_snake; // 下标 0 = 蛇头,末尾 = 蛇尾QVector::prepend就是头部插入,removeLast是尾部删除,两个 API 语义清晰,性能在这个规模下不是瓶颈。真要优化,等蛇长上千再说,那时候该换的是整个渲染方案而不是容器。
4.2 走一步的顺序:先判头、再插头、后删尾
onTick是整个游戏唯一推进状态的地方,它的执行顺序决定了三个经典 bug 会不会出现:撞墙没判、吃到食物没长、蛇头撞到自己尾巴误判。正确的顺序是四段:
void GameWidget::onTick() { if (m_paused || m_over) return; // 第一段:把缓冲方向落地,同时再兜一次反向校验 if (m_pendingDir + m_dir != QPoint(0, 0)) m_dir = m_pendingDir; // 第二段:算出新蛇头,先判边界 const QPoint head = m_snake.first() + m_dir; if (head.x() < 0 || head.x() >= kCols || head.y() < 0 || head.y() >= kRows) { m_over = true; update(); return; } // 第三段:判断是否吃到。吃到就不删尾,等价于长度 +1 const bool eat = (head == m_food); if (hitBody(head, /*ignoreTail=*/!eat)) { m_over = true; update(); return; } // 第四段:插入新头,按需删尾 m_snake.prepend(head); if (eat) { m_score += 10; spawnFood(); } else { m_snake.removeLast(); } update(); // 只标脏,真正的绘制在 paintEvent }第三段的ignoreTail参数是重点。蛇不吃食物时,尾巴这一跳会往前挪一格,也就是说“尾巴即将离开的那个格子”是合法的落点。如果碰撞判定把尾巴也算进去,就会出现玩家贴着尾巴拐弯却莫名死亡的玄学。所以没吃到食物时判定要忽略最后一节,吃到了才把整条蛇都算上——因为那一跳尾巴不挪。
bool GameWidget::hitBody(const QPoint &p, bool ignoreTail) const { const int n = m_snake.size() - (ignoreTail ? 1 : 0); for (int i = 0; i < n; ++i) if (m_snake.at(i) == p) return true; return false; }m_over设为true之后定时器并没有停,onTick会在开头直接 return。这样处理比m_timer.stop()简单,也不会因为忘记重启定时器导致重开后不动。状态标志比资源开关好维护,这是个小经验。
4.3 食物生成:等概率落在空格上,别用 while 重试
新手写食物生成多半是这样:随机取一个格子,如果撞到蛇就重来。蛇短的时候没问题,蛇长到占满八成格子的时候,这个循环的期望重试次数会飙到五次以上,而且理论上可能死循环。残局里卡顿甚至卡死,多半是它干的。
稳妥的写法是把所有空格收集起来,一次性等概率抽取。格子总数是固定的 576 个,收集一遍不过几百次比较,一跳一次,开销可忽略:
void GameWidget::spawnFood() { const int total = kCols * kRows; if (m_snake.size() >= total) { // 满屏即通关 m_over = true; return; } QVector<QPoint> freeCells; freeCells.reserve(total - m_snake.size()); for (int y = 0; y < kRows; ++y) { for (int x = 0; x < kCols; ++x) { const QPoint p(x, y); if (!m_snake.contains(p)) freeCells.append(p); } } // QRandomGenerator 是 Qt5.10 之后推荐的随机源,别再用 qrand() m_food = freeCells.at(QRandomGenerator::global()->bounded(freeCells.size())); }QRandomGenerator::global()->bounded(n)返回[0, n)的均匀整数,比qrand() % n好两点:没有取模偏差,且线程安全。qrand()在新版本里已经被标记为不合适,新写的代码别再用。
还有一处隐含约束:这个函数必须在蛇身更新之后调用。如果先spawnFood()再prepend,新食物有可能正好生成在新蛇头的位置上,视觉上表现为“食物瞬移”或者“吃到了但不加分”。顺序错了不会崩,但会让人觉得游戏逻辑不干净。
4.4 计分、加速与难度曲线
固定速度的贪吃蛇玩三十秒就腻,难度曲线是让它“耐玩”的唯一手段。最简方案是每吃一个食物把定时器间隔减几毫秒,但要设下限,否则后期蛇速快到人类反应不过来。
void GameWidget::bumpDifficulty() { constexpr int kMinInterval = 80; // 手速上限,低于这个值就不讲道理了 constexpr int kStep = 4; // 每吃一个提速 4ms const int next = qMax(kMinInterval, m_timer.interval() - kStep); m_timer.setInterval(next); }把它接在m_score += 10后面调用即可。间隔从 150ms 一路压到 80ms,蛇速翻近一倍,配合逐渐变长的身体,难度是双维度上升的,比单纯加速或单纯变长都更有层次。
分数设计也有讲究。固定 +10 太单调,可以按当前速度给分,速度越快单个食物越值钱:m_score += 10 + (150 - m_timer.interval()) / 4;。这样追求高分就必须接受快节奏,玩家自己会选择风险。这类微调不影响正确性,但对“玩起来有没有意思”影响很大,值得花十分钟调。
5. 排查实录:五个把我卡住的坑
这一章全是血泪经验,每一条都是我在不同机器、不同 Qt 版本上真实撞过的。按“现象 → 原因 → 解决”写,遇到对应症状直接对号入座。
5.1 编译与链接阶段的三个坑
现象:编译通过,链接报undefined reference to vtable for GameWidget,还附了一堆moc_相关的未定义符号。
原因:类里写了Q_OBJECT宏,但 moc 没有对该头文件运行。常见于手写 Makefile 的工程,或者 CMake 里CMAKE_AUTOMOC没开、头文件又没列进add_executable。
解决:CMake 工程开set(CMAKE_AUTOMOC ON),并把.h加进源文件列表;改完删掉整个build/重新配置。手写 Makefile 的话,得手动moc gamewidget.h -o moc_gamewidget.cpp再一起编译,麻烦且易错,不如换 CMake。
现象:find_package(Qt6 COMPONENTS Widgets REQUIRED)报找不到配置文件,但qmake -v明明显示装了 Qt。
原因:装的是 Qt5 而 CMake 找的是 Qt6,或者反过来。两套开发包共存时,CMake 的搜索路径由CMAKE_PREFIX_PATH和包自带的Qt6Config.cmake决定,跟qmake指向哪套没有必然关系。
解决:先确认find_package的版本号和系统里实际装的一致。要写兼容两代的构建脚本,用这个写法一次覆盖:
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) target_link_libraries(snake PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)现象:find_package成功,编译也过了,链接阶段报undefined reference to QWidget::~QWidget()之类的 Qt 符号缺失。
原因:target_link_libraries漏写,或者写了但目标名不对(比如写成了Qt6::Widget,少个 s)。
解决:核对目标名。Qt6 的模块目标统一是Qt6::<Module>格式,Widgets 模块就是Qt6::Widgets。链接行必须在add_executable之后,顺序颠倒 CMake 会静默忽略。
5.2 运行期的两个坑
现象:窗口正常弹出,蛇也在动,但按方向键毫无反应,只有鼠标点一下窗口之后键盘才生效。
原因:QWidget默认的焦点策略是Qt::NoFocus,键盘事件根本不会派发到这个控件上。窗口刚显示时焦点可能在别处,点一下等于手动把焦点给了它。
解决:构造函数里加setFocusPolicy(Qt::StrongFocus),并且在show()之后调一次setFocus()。两行都要,只加前者在某些窗口管理器下首次显示仍然拿不到焦点。另外注意:如果界面上还放了按钮之类的子控件,它们会抢焦点,纯游戏窗口就别放。
现象:贴着蛇尾拐弯的时候,莫名其妙判负,明明还有一格空隙。
原因:碰撞判定把即将离开的尾巴格子也算成了障碍。这个 bug 在蛇短的时候几乎不出现,蛇一长就频繁触发,很容易被误判成“随机死亡”的玄学。
解决:按 4.2 里的做法,未吃到食物时判定忽略最后一节(ignoreTail = true),吃到时才全量判定。判断依据是“这一跳尾巴会不会挪”,而不是“蛇有多长”。
现象:玩到后期蛇很长时,画面开始一顿一顿的,但 CPU 占用并不高。
原因:多半是paintEvent里做了不该做的事——每次重绘都new QPainter、重复构造QColor对象、或者在绘制循环里调用了m_snake.contains()这类线性查找。
解决:QPainter放栈上(QPainter p(this);),颜色常量提到类静态成员,绘制循环只做纯几何运算。碰撞判定里的线性查找保留没关系,它一跳只跑一次;绘制是一帧跑几百次,热点在那边。
6. 从“能跑”到“耐玩”:固定步长、状态机与手感调参
代码跑通之后,真正决定这个项目值不值得留在自己仓库里的,是手感。手感不是玄学,它由三个可量化的东西决定:时间步长是否稳定、状态切换是否干净、输入延迟是否可预期。
先说时间步长。QTimer的interval是“至少等这么久”,不是精确周期。系统负载高的时候,两次onTick的实际间隔可能是 150ms、170ms、155ms,蛇的移动速度就会有肉眼可见的抖动。要做稳定手感,得改用固定步长累加:定时器设成 16ms 高频跑,用QElapsedTimer累加真实流逝时间,攒够一个逻辑步长才推进一次。
void GameWidget::onFrame() { m_elapsed += m_clock.restart(); // 返回上次 restart 至今的毫秒数 while (m_elapsed >= m_stepMs) { // 攒够一步就推进,可能一次推多步 m_elapsed -= m_stepMs; onTick(); } update(); // 每帧都重绘,保证动画平滑 }m_clock.restart()一举两得:既返回流逝时间,又把计时器归零。while而不是if,是为了在系统卡顿后把欠下的步数补上,否则蛇会“掉帧式”落后于实际时间。这个模式叫固定时间步长,是所有实时逻辑的通用做法,值得记住。
再说状态机。用m_paused和m_over两个布尔量管理状态,写到第四个状态(比如“开始界面”)就会失控,组合爆炸。改成枚举,四个状态互相之间的转换路径一目了然:
| 状态 | 进入条件 | 该状态下 onTick 的行为 |
|---|---|---|
| Ready | 程序启动、按 R | 不推进,只响应开始键 |
| Running | 从 Ready 按空格 | 正常推进 |
| Paused | Running 中按空格 | 直接 return |
| Over | 撞墙或自撞 | 直接 return,等待 R |
枚举做状态判断,布尔量只作为“上一次是什么状态”的辅助记录,这个结构扩到十个状态都不会乱。
最后是参数调优。这份源码里几个常量我按自己的偏好给了一组推荐值,你可以直接改着试,改完立刻能感受到差别:
| 参数 | 保守值 | 我的常用值 | 激进值 | 影响 |
|---|---|---|---|---|
kCell | 20 | 24 | 32 | 窗口尺寸与方块观感 |
| 基础间隔 | 180ms | 150ms | 100ms | 起步速度 |
| 每吃一个提速 | 2ms | 4ms | 8ms | 难度爬升斜率 |
| 最低间隔 | 100ms | 80ms | 60ms | 手速天花板 |
| 输入缓冲深度 | 1 | 1 | 2 | 极速连按的跟手程度 |
调参的顺序建议是:先定kCell(决定窗口大小,改一次全盘跟着变),再定基础间隔(决定第一分钟的感受),最后调提速和下限(决定能玩多久)。反过来调会不停地互相推翻。
有一个坑我踩过不止一次,得提醒:改完kCell之后忘了改setFixedSize里的乘数,或者忘了刷新窗口尺寸,会导致右侧和下侧各留一条黑边,蛇跑到边上就看不见了。从那以后我每次动网格参数,都强制走一遍“改常量 → 删 build → 重编译 → 跑到四个边界各撞一次”的流程,四边都能正确判负才算改对了。希望帮到你。
本文还有配套的精品资源,点击获取