
简介面向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 的QWidgetQPainter组合足够而且键盘事件、定时器、窗口缩放这些都在同一个 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_TYPERelease cmake --build . -j$(nproc) ./snake-DCMAKE_BUILD_TYPERelease会打开优化对这个体量的程序帧率影响不大但养成习惯没坏处调试期改Debug方便断点交付前切Release。-j$(nproc)用满所有核并行编译几秒钟就完事。程序起来之后别急着玩先按这份清单核对一遍任何一条不对说明源码被改过或者环境有差异检查项预期表现不符合时先看哪里窗口尺寸正方形约 576×576 像素kCols/kRows/kCell三个常量初始蛇长3 节朝右resetGame()里的初始化首次按键蛇立即改向无延迟setFocusPolicy是否设置吃到食物长度 1分数 10onTick里 eat 分支撞墙 / 撞自己停止移动画面定格碰撞判定的边界条件如果你在没有图形界面的环境里比如远程终端只想验证它能跑起来不崩可以用离屏平台插件跑一次程序不会开窗但会正常初始化并进入事件循环几秒后手动结束QT_QPA_PLATFORMoffscreen 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_pendingDirvoid 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 的真实差别蛇的操作模式非常固定头部插入、尾部删除、按索引随机访问绘制用、整体包含判断碰撞用。这四个操作的权重决定了容器选择。QVectorQt6 里QList就是它底层是连续数组随机访问 O(1)头部插入 O(n) 要整体搬移。看起来头部插入是劣势但蛇长撑死几百节搬移几百个 8 字节的QPoint在现代 CPU 上不到一微秒一跳才做一次完全无感。而随机访问在每一帧绘制里被调用几百次这个才是热点。std::deque头尾插入都是 O(1)听着更配但它的随机访问要过一层间接寻址缓存局部性差绘制循环里反而更慢。QList在 Qt5 时代是指针数组元素小于指针大小时有额外分配对QPoint这种 8 字节类型反而不如QVector。我的选择是QVectorQPointindex 0永远是蛇头。这条约定要写死在注释里因为后面所有first()/last()的语义都依赖它QVectorQPoint 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; } QVectorQPoint 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 按空格正常推进PausedRunning 中按空格直接 returnOver撞墙或自撞直接 return等待 R枚举做状态判断布尔量只作为“上一次是什么状态”的辅助记录这个结构扩到十个状态都不会乱。最后是参数调优。这份源码里几个常量我按自己的偏好给了一组推荐值你可以直接改着试改完立刻能感受到差别参数保守值我的常用值激进值影响kCell202432窗口尺寸与方块观感基础间隔180ms150ms100ms起步速度每吃一个提速2ms4ms8ms难度爬升斜率最低间隔100ms80ms60ms手速天花板输入缓冲深度112极速连按的跟手程度调参的顺序建议是先定kCell决定窗口大小改一次全盘跟着变再定基础间隔决定第一分钟的感受最后调提速和下限决定能玩多久。反过来调会不停地互相推翻。有一个坑我踩过不止一次得提醒改完kCell之后忘了改setFixedSize里的乘数或者忘了刷新窗口尺寸会导致右侧和下侧各留一条黑边蛇跑到边上就看不见了。从那以后我每次动网格参数都强制走一遍“改常量 → 删 build → 重编译 → 跑到四个边界各撞一次”的流程四边都能正确判负才算改对了。希望帮到你。本文还有配套的精品资源点击获取