
简介一份基于C语言、采用Qt5框架实现的打地鼠游戏完整工程源码面向高校期末大作业、课程设计等场景适合有一定C语言基础、希望系统学习Qt图形界面开发的读者。项目围绕游戏场景与交互逻辑展开包含场景管理、地鼠与锤子图元、鼠标点击响应、分数判定等核心模块并配有详细代码注释逻辑清晰新手也能较快读懂。资源包共22个文件其中5个cpp源文件与4个h头文件构成主要程序逻辑ui文件负责窗口布局pro与qrc文件管理工程配置和资源引用另有8张png及1张jpg图片素材用于游戏角色与背景绘制压缩包整体仅226KB轻量易部署。目前已有50人学习下载。通过该工程可以直观了解Qt5的事件处理、定时器刷新、图形视图框架以及图片资源加载等常用技术拿到后稍作配置即可运行还能在此框架上继续扩展关卡、音效、计分板等功能是课程设计与期末大作业的高分参考。1. “C语言 Qt5”打地鼠为什么这种题能拿 98 分到底难在哪期末周最忙的时候你接到一个课题不用 C只用 C 语言写逻辑再用 Qt5 套一层图形界面实现打地鼠游戏。看着像两个不相干的工具拼在一起实际上这是 C 语言课程设计里很成熟的高分套路。把游戏规则、计分、随机、碰撞这些核心代码全部放在.c文件里Qt5 只负责画窗口、画洞、画老鼠、接收鼠标点击那么 C 语言的考核要点和界面效果都不缺往 98 分冲是现实目标。这篇笔记就按能直接复现的顺序把模块拆分、工程搭建、加分项和混编的坑写清楚。适合正在准备 C 语言大作业的学生、想用 Qt5 做小工具的开发者还有需要快速交付一个能演示的程序的人。2. 把打地鼠拆成四个模块结构体、回调、定时器、碰撞检测的分工打地鼠这类“小游戏大作业”最怕一上来就写界面。我见过不少同学先把按钮和图片摆好再回头补逻辑结果逻辑全写在按钮的槽函数里最后自己都找不到得分规则在哪。正确的顺序是把游戏本身当作一个独立于 Qt 的模块它不关心鼠标是怎么来的不关心窗口多大只回答“现在这一帧老鼠在哪、该不该消失、点这里中没中”。这才符合标题里“基于 C 语言”的意思——不是 C 语言去调用 Qt而是 C 语言负责全部规则Qt 只当显示器。2.1 把老鼠抽象成结构体一开始就别写散变量如果你用mole1_x、mole2_y、mole3_active这种平铺变量后面每加一个功能都要改一堆代码。常见做法是定义一个Mole结构体再用定长数组管理多个地鼠这样遍历、随机生成、命中检测都能用循环解决。/* game_logic.c */ #define MAX_MOLES 6 typedef struct Mole { int active; /* 1 当前显示0 已消失 */ int x, y; /* 圆心坐标单位像素和 Qt 绘制坐标一致 */ int radius; /* 命中半径画多大的圆就设多大 */ int left_ms; /* 剩余停留毫秒归零就隐藏 */ int id; /* 标记属于哪个洞方便画成不同位置 */ } Mole; static Mole moles[MAX_MOLES]; int board_w 0; int board_h 0;这里把结构体定义成static意思是只在本文件内部使用外部一律通过函数访问。这是 C 语言课设里很吃分的封装习惯隐藏内部数据暴露接口。radius不要写死让它跟绘制尺寸绑定后面调整窗口大小时只改一处。数组大小根据难度选6 个在 640x480 窗口上已经足够热闹。如果开太多玩家会眼花太少生成间隔拉不长难度曲线会失真。left_ms是后续定时器逻辑的核心字段它让 C 语言自己知道“该走了”不需要 Qt 端帮忙计时。2.2 C 语言与 Qt 的桥接extern C 与函数指针Qt 的本质是 C 库纯 C 代码不能直接创建QWidget、连接QTimer。所以“基于 C 语言的 Qt5 程序”在实现上必然是分层.c文件写逻辑.cpp文件写界面。为了让 C 代码能调用 C 函数且不出链接错头文件需要加上extern C保护。这同时也是 C 语言考核里“接口设计”和“指针”的得分点。/* game_logic.h */ #ifndef GAME_LOGIC_H #define GAME_LOGIC_H #define MAX_MOLES 6 typedef struct Mole Mole; #ifdef __cplusplus extern C { #endif void game_init(int width, int height); void game_tick(int elapsed_ms); /* 每次定时器触发时调用 */ int game_hit(int click_x, int click_y); /* 命中检测返回本次得分 */ int game_get_score(void); int game_get_combo(void); int game_get_level(void); void game_get_moles(Mole out[], int *count); #ifdef __cplusplus } #endif #endifextern C的作用是告诉 C 编译器这些函数的符号按 C 规则生成不要做 C 的名字修饰。没有它C 端调用game_hit时会去找一个带参数类型信息的修饰名链接阶段必然报“unresolved external symbol”。这个问题在混合工程里出现频率很高后面避坑章节还会说。这段接口设计也回答了标题里“基于 C 语言”的落地方式C 语言用结构体管理数据用函数指针和回调思路组织流程Qt 端只是这些函数的调用方。你甚至可以把game_tick理解成一个由定时器驱动的回调函数这是 C 语言里很实用的设计手法。2.3 定时器为什么不能 while(1) 等老鼠新手的常见做法是写一个死循环while(1) { check(); delay(50); }结果窗口直接白屏无响应。因为 Qt 是事件驱动的while(1)会把事件循环卡死鼠标点击、重绘、窗口关闭全都没机会处理。正确做法是用QTimer周期性触发槽函数在槽里调用 C 语言函数让逻辑和界面都在主线程里顺序执行。/* MainWindow 构造函数片段 */ QTimer *timer new QTimer(this); timer-setInterval(50); connect(timer, QTimer::timeout, this, MainWindow::onTick); timer-start();setInterval(50)表示每 50 毫秒触发一次onTick。在这个频率下老鼠的移动和消失看起来是连续的同时 CPU 开销也低。设计时让所有时间字段都以毫秒为单位onTick里直接把 50 传给game_tick(50)C 逻辑就能精确维护“这只老鼠还有多久消失”。不用sleep的另一个原因是游戏还要同时响应点击。如果在 C 语言里Sleep(50)用户点击事件会被推迟体验就像开了高延迟答辩时老师一点就能感觉出来。2.4 碰撞检测的数学少走弯路的圆心判定判断鼠标是否点中地鼠最常见的做法是算“点到圆心的距离”。如果距离小于等于半径就算命中。这里有个小优化比较距离的平方避开sqrt开方运算不仅少一次浮点计算还避免了大作业里被问“你为什么要 include math.h”的尴尬。int game_hit(int click_x, int click_y) { for (int i 0; i MAX_MOLES; i) { if (!moles[i].active) continue; int dx click_x - moles[i].x; int dy click_y - moles[i].y; if (dx * dx dy * dy moles[i].radius * moles[i].radius) { moles[i].active 0; /* 打中就立刻消失防止重复得分 */ return 10 combo * 2; /* 连击加分规则在 C 端维护 */ } } return 0; }注意这里判断用的是刚好压在圆边上也算命中玩家容错更高体验更友好。combo是连击数根据玩家上一次命中情况动态变化这个字段让评分维度一下丰富起来。命中后立即把active置 0再配合update()触发的重绘就不会出现同一只老鼠被反复点击反复加分的问题。3. 搭出一个能跑的 C 语言 Qt5 混合工程目录、代码、编译全流程模块拆好了接下来就是把代码放进工程并编译。很多人卡在“Qt Creator 默认建的是 C 工程怎么加 C 文件”其实 Qt 的构建系统本身不排斥.c后缀它会自动按 C 编译器处理。只要目录结构清楚、.pro文件写对编译是一条直线的事。3.1 创建 Qt5 工程并挂上 C 文件我通常不用 Qt Creator 的向导建空工程而是手动建目录这样对工程结构更可控。一个最小可编译工程长这样whack_mole/ ├── whack_mole.pro ├── main.cpp ├── mainwindow.h ├── mainwindow.cpp ├── game_logic.h └── game_logic.c外面这个目录名要避免中文和空格MSVC 工具链对中文路径有时会抽风。.pro文件内容如下QT core gui widgets TARGET whack_mole TEMPLATE app CONFIG c11 SOURCES \ main.cpp \ mainwindow.cpp \ game_logic.c HEADERS \ mainwindow.h \ game_logic.hgame_logic.c直接写进SOURCESqmake 看到.c后缀就自动调用 C 编译器编译不需要额外设置。QT widgets在 Qt5 里必须写否则QWidget相关类全部不可用Qt4 时代没有这一行网上老教程常常漏掉照着抄必翻车。如果你习惯命令行而不是 Qt Creator编译流程是qmake whack_mole.pro make ./whack_moleWindows 上如果装了 Qt 自带的 MinGW在“Qt 5.x for Desktop”命令行里执行同样命令即可。注意别用 VS Code 的 C 插件直接编译整个 Qt 工程那类环境只能编译单文件Qt 的头文件和资源系统它不认。想省事就老老实实用 Qt Creator 或 Visual Studio Qt 插件。3.2 C 语言核心逻辑源码走读C 端代码是这门大作业的得分主体。设计时让所有逻辑都集中在game_logic.cQt 端不出现任何得分规则。先看初始化和定时器的核心部分static int score 0; static int combo 0; static int level 1; static int next_spawn_ms 400; void game_init(int width, int height) { board_w width; board_h height; score 0; combo 0; level 1; next_spawn_ms 400; memset(moles, 0, sizeof(moles)); } void game_spawn(void) { for (int i 0; i MAX_MOLES; i) { if (!moles[i].active) { moles[i].active 1; moles[i].x 40 rand() % (board_w - 80); moles[i].y 60 rand() % (board_h - 120); moles[i].radius 36; moles[i].left_ms 700 rand() % 500; break; } } } void game_tick(int elapsed_ms) { for (int i 0; i MAX_MOLES; i) { if (moles[i].active) { moles[i].left_ms - elapsed_ms; if (moles[i].left_ms 0) moles[i].active 0; } } next_spawn_ms - elapsed_ms; if (next_spawn_ms 0) { game_spawn(); next_spawn_ms 400 - (level - 1) * 40; if (next_spawn_ms 150) next_spawn_ms 150; } }elapsed_ms参数让 C 逻辑不关心外部用的是什么定时器只管“过去了多少时间”。这是很干净的独立模块设计。rand() % (board_w - 80)控制生成范围40到board_w - 40之间避免老鼠出现在窗口边缘被裁掉一半。left_ms初始为700 rand() % 500也就是 700 到 1200 毫秒之间随机玩家反应时间够用又不至于等太久。next_spawn_ms初始 400 毫秒说明第一关老鼠来得密想不丢分得手快。每升一级生成间隔减 40 毫秒下限 150 毫秒防止后期快到手抽筋。这些数字不是拍脑袋定的答辩时老师问“为什么 400”“为什么下限 150”你要能答出“按普通人反应时间约 200 毫秒左右设计下限略低于它”。C 语言考核里还有一个常见考点是文件读取。这里我用fgets安全地读取最高分记录正好覆盖字符串处理和缓冲区安全两个知识点void game_load_record(void) { FILE *fp fopen(record.txt, r); if (!fp) return; char buf[32]; if (fgets(buf, sizeof(buf), fp)) { best_score atoi(buf); } fclose(fp); }fgets比gets安全因为它能限制读取长度不会让超长输入把缓冲区撑爆。这是课程设计里很常见的“老师故意问哪里不安全”的考点提前用上等于提前得分。这里只读不写写最高分的逻辑放在游戏结束时。3.3 Qt 窗口端源码走读Qt 端只做三件事创建窗口、绘制画面、把鼠标事件转交给 C 函数。main.cpp里有两个容易被忽略的设置#include QApplication #include QTime #include cstdlib #include mainwindow.h int main(int argc, char *argv[]) { QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication app(argc, argv); srand(static_castunsigned(QTime::currentTime().msec())); game_load_record(); MainWindow w; w.show(); return app.exec(); }setAttribute必须写在创建QApplication之前否则高分屏下整个界面会模糊点击坐标还会偏移。srand只需要种一次别放在game_tick里否则每帧都重置随机序列生成的地鼠位置会不断重复。game_load_record()在窗口显示前执行保证标题栏或角落的“最高分”一开始就有数据。MainWindow的鼠标事件是坐标衔接的关键void MainWindow::mousePressEvent(QMouseEvent *e) { int gained game_hit(e-pos().x(), e-pos().y()); if (gained 0) { update(); /* 请求重绘不用主动画 */ } }e-pos()返回的是窗口客户区坐标原点在左上角这和 C 逻辑里game_init(width, height)用的是同一套坐标系。很多混编项目坐标错位就是因为一边用了全局坐标一边用了控件相对坐标。这里约定“都使用窗口客户区坐标”整个程序里就不用再做坐标换算。绘制部分用paintEvent重绘时从 C 端读取所有地鼠的状态void MainWindow::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), QColor(58, 134, 76)); /* 草地底色 */ Mole molesOut[MAX_MOLES]; int count 0; game_get_moles(molesOut, count); for (int i 0; i count; i) { if (molesOut[i].active) { painter.setBrush(QColor(155, 98, 52)); painter.drawEllipse( molesOut[i].x - molesOut[i].radius, molesOut[i].y - molesOut[i].radius, molesOut[i].radius * 2, molesOut[i].radius * 2); } } }fillRect先铺一层绿色背景让画面像草坪。game_get_moles把 C 端结构体数组拷贝出来注意这里传给 Qt 的是结构体快照不是指针避免 Qt 绘制过程中 C 端数据被并发修改。绘制时用radius * 2作为椭圆直径圆心坐标位于中心所以左上角要减掉一个半径这个细节写错老鼠就会画到洞外。4. 冲高分的关键在细节难度递增、连击、音效与暂停的实现顺序一个“能打”的游戏和一个“能拿 98 分”的游戏差距全在细节上。老师评分时会看的不只是功能点还有游戏是否好玩、代码是否分层、答辩时有没有可讲的东西。下面几个加分项建议按顺序做先难度递增再连击计分然后音效和暂停。顺序反了容易在改规则时把界面代码弄乱。4.1 难度递增别用硬编码用 level 算出全部参数难度递增最容易翻车的写法是在 Qt 端塞一堆if (score 50) timer-setInterval(30)。这会让 C 逻辑无法独立测试评分看到逻辑漏在界面层也会扣分。正确做法是 C 端维护level所有难度参数都由它推导关卡生成间隔(ms)老鼠停留(ms)单次基础分140090010232075015324060020415045025这些参数在 C 端统一计算核心代码只有几个三元表达式和边界保护int spawn_interval_by_level(void) { int interval 400 - (level - 1) * 80; return interval 150 ? 150 : interval; } int stay_ms_by_level(void) { int stay 900 - (level - 1) * 150; return stay 300 ? 300 : stay; }等级提升的触发可以用累计命中次数也可以按分数。用分数更直观答辩时容易讲解。game_hit命中后判断score level * 100就level注意别在game_tick里升级否则停顿那一刻玩家正好打空体验奇怪。4.2 连击计分把 Combo 规则封在 C 端Qt 只管显示连击是让游戏从“无聊点击”变成“有点上瘾”的关键。规则很简单连续命中不中断连击数递增一旦漏掉一只老鼠连击清零。漏掉的定义要明确老鼠停留时间结束自动消失就视为漏掉。所以在game_tick里当left_ms 0时要把combo置零void game_tick(int elapsed_ms) { for (int i 0; i MAX_MOLES; i) { if (moles[i].active) { moles[i].left_ms - elapsed_ms; if (moles[i].left_ms 0) { moles[i].active 0; combo 0; /* 这只溜走了连击断 */ } } } /* ... spawn 逻辑 ... */ }而game_hit命中时递增combo用10 (combo - 1) * 2计算当前击中得分连击越高得分越猛。这样玩家会在漏打时感到明显损失游戏的紧张感就出来了。Qt 端只需要把game_get_combo()的返回值画在分数下面不用理解规则。4.3 音效和动画让点击有反馈别让窗口静悄悄没有音效的打地鼠就像哑剧老师演示时按下去没有任何反馈体验分上不去。Qt5 里最简单的做法是用QSoundEffect播放短 WAV#include QSoundEffect QSoundEffect *hitSound new QSoundEffect(this); hitSound-setSource(QUrl::fromLocalFile(hit.wav)); hitSound-setVolume(0.6f); /* 命中后调用 */ hitSound-play();QSoundEffect适合播放短音频加载效率高且是异步播放不会卡界面。注意 Qt5 的 multimedia 模块要在.pro里加QT multimedia安装 Qt5 时如果没勾这个组件编译会报“找不到 QSoundEffect 头文件”重装或单独添加组件就能解决。动画最简单的是打中瞬间把老鼠放大一闪再消失。捕捉这个效果可以用QPropertyAnimation动画缩放半径不过要小心缩放期间 C 端的半径没变碰撞检测和显示不同步点空会很莫名。稳妥做法是命中后让老鼠闪烁 80 毫秒再消失做法是在game_hit命中后不立刻置active 0而是置成一种“被打中”状态Qt 绘制时对这种状态画成灰色并缩小 20%。这样不用开定时器动画视觉反馈也足够清楚。4.4 暂停与重新开始处理边界状态暂停功能能防止“游戏还没进入状态程序就结束了”的尴尬。实现上用 Qt 的keyPressEvent监听空格键暂停本质就是停止定时器void MainWindow::keyPressEvent(QKeyEvent *e) { if (e-key() Qt::Key_Space) { paused !paused; paused ? timer-stop() : timer-start(); } }暂停时老鼠状态保留恢复后继续计时玩家可以切出去查资料再回来接着打。这个功能虽然简单但能应对答辩现场的意外情况比如老师突然提问你按下空格就能让游戏“冻结”。重新开始需要调用game_init并重置计时器注意先停旧计时器再启动新的避免两个定时器事件叠加。5. 避坑记录Qt5 与 C 语言协作时最容易翻车的五个点混编项目的特点是你不仅要面对 C 语言的坑还要面对 C 和构建系统的坑。下面五条是我做 Qt 课设遇到的高频问题每一条都是血泪经验换来的按“现象 → 原因 → 解决”写能帮你少踩一半的坑。5.1 现象CMake 报错qt5config.cmake找不到用 CMake 而不是 qmake 时经常出现CMake Error at C:/Qt/qt5.9.4/5.9.4/msvc2017_64/lib/cmake/Qt5/Qt5Config.cmake:30原因通常是CMAKE_PREFIX_PATH指向的 Qt 路径和你实际安装的不一致或者编译器位数不匹配。比如你装的是msvc2017_64却在用 MinGW 的 CMake 编译器去查找 MSVC 的 Qt 库那必然找不到对应配置。解决方式是打开 CMakeLists.txt把路径显式指到当前使用的 Qt 套件目录set(CMAKE_PREFIX_PATH C:/Qt/5.15.2/msvc2019_64) find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED)同时删掉构建目录里的CMakeCache.txt否则修改了路径也不重新探测。更省心的选择是大作业直接用 qmake.pro文件不涉及路径查找Qt Creator 会自动匹配当前 Kit 对应的 Qt 版本省掉一大半构建问题。5.2 现象链接报错unresolved external symbol game_hitC 文件编译正常但链接阶段说找不到game_hit之类的符号。这多半不是代码逻辑问题而是 C 符号名修饰搞的鬼。C 在编译时会改写函数名把参数类型编码进去而 C 编译器不干这事。mainwindow.cpp是 C它期望调用的game_hit名字被修饰过game_logic.c是 C生成的符号是原始名两者对不上。解决是在 C 语言头文件里加extern C保护同时确保game_logic.c包含的头文件也是同一个版本。有些同学把extern C只写在.cpp文件里.c文件自己另写了一份没保护的声明结果还是错。正确做法是头文件统一用#ifdef __cplusplus包裹两边包含同一个头文件。5.3 现象界面中文显示乱码在 Windows 上MSVC 编译 Qt 程序时源码文件如果是 UTF-8 无签名格式字符串里的中文字面量在运行时很可能变成乱码。尤其是setWindowTitle(打地鼠)这类直接写在代码里的中文最容易被编译器按本地代码页错误解释。解决方式有两个选一个就行一是把源码文件另存为 UTF-8 with BOMMSVC 能正确识别编码二是避免中文面字量直接进代码用QString::fromUtf8(打地鼠)显式转换。更激进一点的做法是全部用英文界面文本但课设要求中文界面时还是用QString::fromUtf8最保险因为即使别人用-stdc11重新编译 C 文件也不会影响 C 文件里的编码逻辑。5.4 现象高分屏下点击位置偏移打不中老鼠或点偏在 125% 或 150% 缩放的 Windows 屏幕上Qt5 程序如果不做高 DPI 处理画面会被系统拉伸而鼠标事件的坐标和绘制逻辑还在逻辑像素里两者不一致于是点击总是偏向某个方向。这在答辩用的教室电脑上非常常见有的教室是 4K 显示器配默认缩放不处理必翻车。解决是main.cpp最开头加上QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);这一行必须出现在创建QApplication之前否则不生效。如果加了还是错检查是不是只有窗口部分手动设置了固定尺寸比如setFixedSize(640, 480)最好改成依据屏幕缩放计算尺寸保证逻辑坐标和绘制坐标一直一致。5.5 现象游戏时间长了节奏漂移老鼠出现得越来越慢QTimer的精度其实比很多人以为的还要差一些尤其在 Windows 上当后台有其它程序占 CPU 时定时器可能被延迟几毫秒到几十毫秒。如果只用“每触发一次 tick 就减 50ms”这种固定减法累计误差会让游戏变得越来越慢老鼠出现频率肉眼可见地下降。解决方式是不依赖 tick 次数而是用真实时间戳差值。Qt 里用QElapsedTimer测量真实经过的毫秒数然后把这个值传给 C 端的game_tick(elapsed_ms)。这样即使某次定时器晚了 100 毫秒C 端也会把这段延迟算进去游戏节奏始终和真实时间一致。这个设计还能让你把QTimer改成 30 毫秒刷新率也不会破坏原有时长计算。6. 答辩前这样验证十分钟验收清单和一个演示节奏最后一步不是写代码而是替老师试玩。我习惯在做完所有功能后花十分钟完整过一遍验收清单别小看这个环节它救过我太多次。先把检查项列成一个表照着点一遍检查项操作预期结果启动双击运行窗口正常显示无乱码无黑块基础命中点击出现的老鼠老鼠消失分数增加有音效空点点击空白处不得分连击断掉不崩溃自动消失不点击老鼠老鼠停留后消失连击清零难度上升连续命中到升关条件生成间隔变短关卡显示变化暂停恢复按空格再按空格游戏冻结再按继续时间正确长时间运行挂机十分钟内存不疯涨无卡顿如果用的 GCC 工具链编译前顺手加一组警告开关看看有没有隐患gcc -stdc99 -Wall -Wextra -Wpedantic -c game_logic.c没有输出就是干净。有 warning 也别全忽略重点看未初始化变量和隐式类型转换这两类最容易被老师挑刺。Valgrind 在 Linux 下可以用来查内存泄漏Windows 下如果装了 Visual Studio直接跑一次调试生成的“诊断工具”看进程退出时堆内存有没有明显增长。演示节奏也很讲究。我先删掉存档文件让最高分从零开始然后开一局故意先漏掉第一只老鼠让连击清零的规则被老师看见接着快速连打三只让分数的跳动幅度展示连击加成升关那一刻故意停手让老师看到节奏明显变快。这套流程比闷头打两分钟更能讲清楚系统的行为边界。我现在做 Qt 混编项目已经养成了“写完就按验收表跑一遍”的习惯尤其是定时器精度和坐标偏移动这种不跑发现不了的问题全靠在交付前多花十分钟。希望这整套从拆模块到避坑的思路能帮你把大作业顺利推到 98 分那条线上希望帮到你。本文还有配套的精品资源点击获取