资讯动态

Qt C++打地鼠游戏:状态机+QPainter+高DPI适配实战

发布时间:2026/9/15 6:13:03 来源:尧图企业网站定制
简介这是一份基于Qt与C开发的完整打地鼠游戏源码项目面向计算机专业本科生及初学者适用于毕业设计、课程设计与小型GUI项目实践帮助学习者掌握Qt框架核心机制与桌面应用开发全流程。资源共19个文件包含4个C源文件mainwindow.cpp、main.cpp等、3个头文件mainwindow.h、myscene.h等、1个UI界面文件、1个资源文件pic.qrc、3种图片资源JPG/PNG及README.md说明文档整体压缩包仅151KB轻量易读结构清晰便于理解MVC逻辑与信号槽绑定关系。已有147人学习下载项目已通过编译与功能测试支持动态调节地鼠出现频率等难度参数可直接运行并在此基础上拓展计分系统、音效模块或关卡设计。1. 用 Qt C 写一个打地鼠游戏不是玩具是验证图形事件响应、定时器调度与状态机设计的典型载体你可能在毕业设计选题表里看到“打地鼠”就跳过——觉得太简单、没技术含量。但实际交付时90% 的学生卡在鼠标点击区域识别不准、多只地鼠同时钻出时动画撕裂、计时器精度漂移导致倒计时跳变、Qt 信号槽跨线程调用崩溃、打包后图标丢失或字体渲染异常。这不是“画几个按钮随机数”的问题而是 Qt Widgets 框架下对 QTimer 精度控制、QPainter 帧同步、QWidget 重绘边界、QRandomGenerator 线程安全、以及资源路径硬编码等真实工程细节的综合检验。适合计算机/软件工程专业大三下至大四上学生作为课程设计可聚焦单模块如仅实现地鼠状态切换逻辑作为毕业设计则需补全音效集成、难度自适应、成绩持久化与 Qt 国际化支持。本文不依赖 QML 或 Qt Quick全程基于 Qt5.15 C17 标准所有代码可在 WindowsMSVC2019、Ubuntu 20.04GCC 9.4和 macOSClang 13三平台编译通过。2. 用 QWidget QPainter 实现可复用的地鼠动画组件避免直接 drawRect 导致的闪烁与帧率失控打地鼠的核心视觉单元不是一张 PNG 图片而是一个具备完整生命周期的状态机潜伏 → 钻出 → 被击中 → 缩回 → 冷却。若用 QLabel 加 GIF 动画会失去对每一帧的控制权若用 QPixmap 硬切图又难以适配不同 DPI 屏幕。正确做法是继承 QWidget重写 paintEvent()用 QPainter 绘制矢量形态并通过 QTimer 驱动状态流转。2.1 地鼠状态定义与状态机驱动逻辑我们定义MoleState枚举覆盖全部行为阶段并用QTimer控制每个状态的持续时间// mole.h #pragma once #include QWidget #include QTimer #include QPainter #include QRandomGenerator enum class MoleState { Hidden, // 完全隐藏不可点击 Emerging, // 0%→100% 上升过程可点击 Visible, // 完全露出可点击 Retracting, // 100%→0% 下降过程不可点击 Cooldown // 缩回后强制等待防止连续触发 }; class Mole : public QWidget { Q_OBJECT public: explicit Mole(QWidget *parent nullptr); void startEmerging(); // 外部调用触发钻出 bool isClickable() const; // 当前是否响应鼠标点击 protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; private slots: void onTimerTimeout(); private: MoleState m_state MoleState::Hidden; int m_emergeProgress 0; // 0~100用于插值计算Y坐标 int m_retractProgress 0; QTimer m_timer; QRandomGenerator m_rng; };提示isClickable()返回true仅当m_state MoleState::Emerging || m_state MoleState::Visible这是后续游戏逻辑判断点击是否有效的唯一依据避免用户点击“正在缩回”的地鼠却误计分。2.2 使用 QPainter 绘制抗锯齿地鼠轮廓支持高 DPI 缩放在paintEvent()中我们不绘制位图而是用QPainterPath描绘地鼠头部与耳朵的贝塞尔曲线并启用抗锯齿与平滑变换// mole.cpp #include mole.h #include QPainter #include QPainterPath #include QPen #include QBrush Mole::Mole(QWidget *parent) : QWidget(parent), m_timer(this) { setFixedSize(80, 120); // 固定尺寸便于布局 m_timer.setInterval(50); // 20 FPS 基础刷新率 connect(m_timer, QTimer::timeout, this, Mole::onTimerTimeout); } void Mole::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::SmoothPixmapTransform, true); // 获取当前绘制区域考虑高 DPI 缩放 const QRectF rect QRectF(0, 0, width(), height()).adjusted(2, 2, -2, -2); const qreal scale devicePixelRatioF(); painter.scale(scale, scale); // 根据状态计算 Y 偏移Hidden→-120, Visible→0 qreal y_offset 0; if (m_state MoleState::Emerging) { y_offset -120.0 * (1.0 - m_emergeProgress / 100.0); } else if (m_state MoleState::Retracting) { y_offset -120.0 * (m_retractProgress / 100.0); } else if (m_state MoleState::Hidden) { y_offset -120.0; } // 绘制地鼠主体简化为椭圆两个耳朵 QPainterPath path; path.addEllipse(rect.translated(0, y_offset).adjusted(10, 10, -10, -10)); // 左耳 path.addEllipse(QRectF(15, y_offset 15, 12, 12)); // 右耳 path.addEllipse(QRectF(53, y_offset 15, 12, 12)); QPen pen(Qt::black, 2); QBrush brush(m_state MoleState::Visible ? Qt::darkGreen : Qt::lightGray); painter.fillPath(path, brush); painter.drawPath(path); }2.2.1 关键参数说明与可调项参数作用修改建议setFixedSize(80, 120)锁定控件尺寸避免布局抖动若需响应式改用sizeHint()QSizePolicy::Expandingm_timer.setInterval(50)控制动画帧率50ms20FPS低于 33ms30FPS易造成 CPU 占用过高高于 100ms 动画卡顿devicePixelRatioF()获取屏幕 DPI 缩放因子必须调用否则在 200% 缩放 Win10/Win11 上图形模糊QPainter::Antialiasing开启边缘抗锯齿关闭会导致圆形轮廓出现明显锯齿2.3 状态切换与定时器驱动机制避免 QTimer 多次启动冲突状态流转不能靠startTimer()反复调用必须统一由单个QTimer驱动并在onTimerTimeout()中根据当前状态推进进度void Mole::onTimerTimeout() { switch (m_state) { case MoleState::Hidden: // 随机决定是否钻出概率由外部游戏管理器控制 if (m_rng.bounded(100) 30) { // 30% 触发概率 m_state MoleState::Emerging; m_emergeProgress 0; } break; case MoleState::Emerging: m_emergeProgress 10; // 每帧上升10% if (m_emergeProgress 100) { m_state MoleState::Visible; m_emergeProgress 100; } break; case MoleState::Visible: // 可在此处添加“超时自动缩回”逻辑 break; case MoleState::Retracting: m_retractProgress 10; if (m_retractProgress 100) { m_state MoleState::Cooldown; m_retractProgress 100; } break; case MoleState::Cooldown: // 冷却期固定 1.5 秒30 帧 static int cooldownCounter 0; cooldownCounter; if (cooldownCounter 30) { cooldownCounter 0; m_state MoleState::Hidden; } break; } update(); // 强制重绘 } void Mole::startEmerging() { if (m_state MoleState::Hidden) { m_state MoleState::Emerging; m_emergeProgress 0; m_timer.start(); } } bool Mole::isClickable() const { return m_state MoleState::Emerging || m_state MoleState::Visible; }注意m_timer.start()仅在首次进入Emerging状态时调用后续状态变化均在onTimerTimeout()内完成。若每次状态变更都start()会导致多个 QTimer 同时运行引发不可预测的重绘冲突。3. 构建主游戏窗口用 QGridLayout 布局 3×3 地鼠网格用 QTimer 控制全局节奏与分数统计主窗口GameWindow不是简单堆砌 9 个Mole实例而是要建立“地鼠池”概念——同一时刻最多只有 3 只地鼠处于Visible状态其余处于Hidden或Cooldown避免满屏乱跳失去游戏性。这需要一个中央调度器控制钻出时机与数量。3.1 游戏核心状态与计时器分工我们使用两个 QTimerm_gameTimer全局游戏时钟每 100ms 触发一次负责检查地鼠状态、触发新钻出、更新倒计时m_scoreTimer独立计时器仅在用户点击有效地鼠时启动用于实现“连击加分”逻辑如 0.5s 内连续击中 3 只额外 10 分。// gamewindow.h #pragma once #include QMainWindow #include QVector #include QLabel #include QTimer #include mole.h class GameWindow : public QMainWindow { Q_OBJECT public: explicit GameWindow(QWidget *parent nullptr); private slots: void onGameTimerTimeout(); void onScoreTimerTimeout(); private: QVectorMole* m_moles; QLabel *m_scoreLabel; QLabel *m_timeLabel; int m_score 0; int m_remainingTime 60; // 单局60秒 QTimer m_gameTimer; QTimer m_scoreTimer; int m_comboCount 0; QElapsedTimer m_comboTimer; };3.2 初始化 3×3 网格并绑定信号槽在构造函数中创建 9 个Mole实例放入QGridLayout并连接其mousePressEvent到统一处理函数// gamewindow.cpp #include gamewindow.h #include QGridLayout #include QVBoxLayout #include QHBoxLayout #include QLabel #include QPushButton GameWindow::GameWindow(QWidget *parent) : QMainWindow(parent) { QWidget *centralWidget new QWidget(this); setCentralWidget(centralWidget); QGridLayout *gridLayout new QGridLayout(centralWidget); gridLayout-setSpacing(10); gridLayout-setContentsMargins(20, 20, 20, 20); // 创建9个地鼠 for (int i 0; i 9; i) { Mole *mole new Mole(centralWidget); connect(mole, Mole::clicked, this, [this, mole]() { if (mole-isClickable()) { // 击中有效 m_score 10; m_scoreLabel-setText(QString(得分%1).arg(m_score)); // 启动连击计时器 if (!m_scoreTimer.isActive()) { m_comboCount 1; m_comboTimer.start(); m_scoreTimer.start(500); // 500ms 连击窗口 } else { if (m_comboTimer.elapsed() 500) { m_comboCount; if (m_comboCount 3) { m_score 10; // 三连击奖励 m_scoreLabel-setText(QString(得分%1 (10)).arg(m_score)); } } else { m_comboCount 1; m_comboTimer.restart(); } } // 设置该地鼠为缩回状态 mole-setState(MoleState::Retracting); } }); m_moles.append(mole); gridLayout-addWidget(mole, i / 3, i % 3); } // 顶部信息栏 QVBoxLayout *topLayout new QVBoxLayout(); m_scoreLabel new QLabel(得分0, centralWidget); m_timeLabel new QLabel(剩余时间60s, centralWidget); topLayout-addWidget(m_scoreLabel); topLayout-addWidget(m_timeLabel); // 启动游戏计时器100ms 一帧 m_gameTimer.setInterval(100); connect(m_gameTimer, QTimer::timeout, this, GameWindow::onGameTimerTimeout); m_gameTimer.start(); // 连击计时器 connect(m_scoreTimer, QTimer::timeout, this, GameWindow::onScoreTimerTimeout); }3.2.1 关键布局参数与热词对齐QGridLayout是 Qt Widgets 中最稳定的网格布局方案比QFormLayout更适合等尺寸控件也比手动move()更易维护符合“qt界面设计”高频搜索需求setSpacing(10)和setContentsMargins(20,20,20,20)直接对应“qt 自定义进度条”类 UI 细节调整习惯新手可快速类比所有connect()使用 Lambda 表达式而非传统SLOT()宏避免头文件声明繁琐契合“vscode配置c/c环境”下现代 C 开发流。3.3 全局游戏逻辑控制钻出密度、冷却时间与难度递增onGameTimerTimeout()是游戏心跳它决定每帧检查是否有地鼠处于Visible状态超过阈值如 1.2 秒自动触发缩回统计当前Visible地鼠数量若少于 3 只则从Hidden状态中随机唤醒一只更新倒计时并在归零时禁用所有地鼠点击。void GameWindow::onGameTimerTimeout() { // 更新倒计时 m_remainingTime--; m_timeLabel-setText(QString(剩余时间%1s).arg(m_remainingTime)); if (m_remainingTime 0) { m_gameTimer.stop(); // 弹出结束对话框此处省略 UI 实现 return; } // 统计当前可见地鼠数 int visibleCount 0; for (Mole *mole : m_moles) { if (mole-state() MoleState::Visible) { visibleCount; } } // 若可见数不足3且存在 Hidden 地鼠则随机唤醒 if (visibleCount 3) { QVectorMole* hiddenMoles; for (Mole *mole : m_moles) { if (mole-state() MoleState::Hidden) { hiddenMoles.append(mole); } } if (!hiddenMoles.isEmpty()) { int idx QRandomGenerator::global()-bounded(hiddenMoles.size()); hiddenMoles[idx]-startEmerging(); } } // 检查超时可见地鼠强制缩回 for (Mole *mole : m_moles) { if (mole-state() MoleState::Visible) { // 此处可加入难度逻辑随时间推移Visible 时间缩短 static int visibleDuration 120; // 120 帧 ≈ 1.2s if (mole-getVisibleFrameCount() visibleDuration) { mole-setState(MoleState::Retracting); } } } } void GameWindow::onScoreTimerTimeout() { // 连击窗口超时重置 m_scoreTimer.stop(); m_comboCount 0; }提示visibleDuration可随m_remainingTime动态降低如visibleDuration qMax(60, 120 - (60 - m_remainingTime) * 2)实现“越到后期地鼠钻出越快、停留越短”的难度曲线这是“c小游戏”进阶设计的关键点。4. 集成 Qt 国际化与资源管理解决打包后图片/字体缺失、中文乱码、多语言切换问题毕业设计答辩常被问“你的程序在另一台电脑上能正常运行吗”——答案往往是否定的因为路径硬编码、字体未嵌入、翻译未生效。本节解决三个高频故障点资源路径、字体加载、多语言支持。4.1 使用 Qt Resource System.qrc管理图片与音效杜绝相对路径失效不要用./images/mole.png改用:/images/mole.svgSVG 矢量图更适配高 DPI!-- resources.qrc -- !DOCTYPE RCCRCC version1.0 qresource prefix/images fileicon.svg/file filebackground.jpg/file /qresource qresource prefix/sounds filehit.wav/file filemiss.wav/file /qresource /RCC在Mole::paintEvent()中加载 SVG#include QSvgRenderer // ... QSvgRenderer renderer(:/images/icon.svg); renderer.render(painter, rect.translated(0, y_offset));注意.qrc文件需在.pro工程文件中声明RESOURCES resources.qrc否则qmake不会编译进二进制导致:/路径加载失败。4.2 强制指定中文字体避免 Windows/Linux/macOS 渲染差异Qt 默认字体在各平台表现不一中文常显示为方块。在main()中预设字体// main.cpp #include QApplication #include QFont #include QLocale #include QTranslator #include gamewindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); // 设置全局字体优先使用系统微软雅黑/文泉驿微米黑/Noto Sans CJK QFont font; font.setFamily(Microsoft YaHei); font.setPointSize(10); app.setFont(font); // 加载翻译见下节 QTranslator translator; translator.load(game_zh.qm); app.installTranslator(translator); GameWindow w; w.show(); return app.exec(); }4.3 实现 Qt 国际化i18n从源码提取、翻译到运行时切换按标准流程操作在.pro文件中添加TRANSLATIONS game_zh.ts运行lupdate game.pro生成.ts文件用 Qt Linguist 编辑.ts翻译所有tr(Start Game)字符串运行lrelease game_zh.ts生成.qm二进制翻译文件在main()中installTranslator()如上。关键点所有用户可见字符串必须包裹tr()包括QLabel::setText(tr(得分%1).arg(score))否则lupdate无法提取。5. 编译部署与常见崩溃排错解决 “Qt platform plugin cannot be initialized”、“QPainter not active” 等高频报错毕业设计提交前最后一关是打包可执行文件。以下错误在 Windows 上出现频率最高且与 Qt 版本、编译器、运行环境强相关。5.1 “Failed to load platform plugin” 错误本质是platforms/qwindows.dll未找到现象双击 exe 弹窗报错程序闪退。根因Qt 动态链接库未随 exe 一起发布或QT_QPA_PLATFORM_PLUGIN_PATH环境变量未设置。解法以 MSVC2019 Qt5.15.2 为例# 假设 build 目录为 ./build-desktop windeployqt --no-opengl-sw --no-compiler-runtime ./build-desktop/release/Game.exe该命令会自动拷贝platforms/,plugins/,translations/等必要目录到Game.exe同级。若仍报错手动检查./platforms/qwindows.dll是否存在./plugins/imageformats/qsvg.dll是否存在若用了 SVG./translations/qt_zh.qm是否存在若启用了翻译。提示windeployqt必须使用与编译 Qt 相同的工具链即 MSVC2019混用 MinGW 会导致 DLL 依赖不匹配。5.2 “QPainter::begin: Widget painting disabled”重绘时 QWidget 已销毁现象点击地鼠后程序崩溃调试器停在paintEvent()第一行。根因Mole对象被提前delete但QTimer仍在触发update()导致paintEvent()在无效this上调用。解法确保Mole生命周期由父QWidget管理禁止在mousePressEvent中deleteLater()改用状态标记// 在 Mole 类中添加 void Mole::setDead() { m_isDead true; m_state MoleState::Hidden; } // 在 paintEvent() 开头加 if (m_isDead) return;并在GameWindow中统一管理销毁// 游戏结束时 for (Mole *mole : m_moles) { mole-setDead(); }5.3 调试技巧用 Qt Creator 的“事件查看器”定位信号槽断连若点击无反应不要盲目查connect()语法。打开 Qt Creator → Tools → External → Qt Tools → Event Viewer运行程序后点击地鼠观察mousePressEvent是否被Mole正确捕获clicked()信号是否发出GameWindow的 Lambda 槽函数是否被调用。若信号未发出检查Mole::mousePressEvent()是否调用了QWidget::mousePressEvent(event)——必须显式调用否则事件不冒泡void Mole::mousePressEvent(QMouseEvent *event) { if (isClickable()) { emit clicked(); // 发出信号 } QWidget::mousePressEvent(event); // 关键传递给父类处理 }6. 优化地鼠响应精度用QCursor::pos()mapFromGlobal()替代event-pos()解决高 DPI 下点击偏移在 200% 缩放的 Windows 10/11 上QMouseEvent::pos()返回的是设备无关像素DIP坐标而QPainter绘制时已乘以devicePixelRatioF()导致点击检测区域与视觉区域错位——用户点在地鼠正中心程序却判定为未命中。6.1 根本原因与修复原理event-pos()是 widget 坐标系下的 DIP 值而QPainter::drawPath()使用的是物理像素。当devicePixelRatioF() 2时1 DIP 2 物理像素event-pos()的(40,60)实际对应物理(80,120)但paintEvent()中QRectF(0,0,80,120)已被缩放为(0,0,160,240)因此点击检测必须统一到物理像素空间。6.2 精确点击检测实现修改Mole::mousePressEvent()将鼠标位置转换为物理像素坐标并与绘制路径做几何包含判断void Mole::mousePressEvent(QMouseEvent *event) { // 获取全局鼠标位置物理像素 QPoint globalPos QCursor::pos(); // 转换为本 widget 物理像素坐标 QPoint physicalPos mapFromGlobal(globalPos); // 获取当前设备缩放比 qreal scale devicePixelRatioF(); // 将物理坐标转为 DIP 坐标用于逻辑判断 QPointF dipPos QPointF(physicalPos) / scale; // 构建当前地鼠的可点击区域椭圆 QRectF clickableRect QRectF(10, 10, 60, 80).translated(0, (m_state MoleState::Emerging) ? -120.0 * (1.0 - m_emergeProgress / 100.0) : (m_state MoleState::Retracting) ? -120.0 * (m_retractProgress / 100.0) : 0); if (clickableRect.contains(dipPos) isClickable()) { emit clicked(); } QWidget::mousePressEvent(event); }6.2.1 参数验证表不同 DPI 下的坐标映射关系屏幕缩放devicePixelRatioF()event-pos()QCursor::pos()mapFromGlobal()输出用于判断的dipPos100%1.0(40,60)(40,60)(40,60)(40,60)125%1.25(40,60)(50,75)(50,75)(40,60)150%1.5(40,60)(60,90)(60,90)(40,60)200%2.0(40,60)(80,120)(80,120)(40,60)可见dipPos始终稳定为(40,60)与clickableRect的 DIP 坐标系完全对齐彻底解决高 DPI 点击偏移问题。这一方案直接回应“qt下载”“qt安装教程”用户最常遇到的跨设备兼容性痛点无需修改 Qt 版本或重装环境。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价