简介:这是面向高校C++课程设计和期末大作业的实用项目资源,提供基于Qt框架的打地鼠游戏完整工程。代码涵盖随机生成地鼠、鼠标点击判定、计时计分、胜利与结束界面等核心模块,既可直接编译运行作为验收成果,也适合作为学习游戏循环、界面布局、信号槽机制和对象管理的样例。压缩包中共有60个文件,包括主程序源文件、头文件、界面定义文件、工程配置文件和大量游戏素材图片,整体大小约4.97MB,目录中同时保留多个开发版本,方便对比各阶段改动。截止目前已有1766人下载使用,许多学生参考本工程完成了类似课程任务。查看代码后可以快速掌握打地鼠游戏的实现思路,也能根据需求自行增加关卡、音效或最高分记录,让期末设计更完整、更有亮点。
1. 打地鼠游戏源码的打开方式:先理解状态,再谈代码
一门 C++ 课设拿到手,很多人下意识先找 main 函数在哪,再把代码读完。但打地鼠这类小游戏真正的分水岭不在图形,而在“状态机”和“输入响应”:地鼠什么时候冒头、什么时候缩回、玩家点中的是哪个洞、分数在哪个时间片结算。你把这四件事理清了,源码读起来就只剩下查漏补缺;理不清,代码再多也只是往 onMouseClick 里堆逻辑。这篇以课程设计常见难度为前提,讲清楚打地鼠游戏的模型怎么建、循环怎么写、测试怎么做,也顺带覆盖你用 VS 或 VSCode 编译时可能遇到的运行库问题。适合正在做期末大作业的在校生,也适合想快速捡起 C++ 小游戏骨架的在职开发者。
2. 打地鼠游戏的模型设计:从需求到数据结构
2.1 打地鼠不是图形问题,是时间片问题
很多课程设计报告把打地鼠写成“一个图形化游戏”,但代码核心其实是一个离散时间模型。游戏世界可以抽象成一张定时刷新的表:每个洞(hole)在某一时刻处于“空、出现、停留、消失”四种状态之一,玩家在特定时间窗口内点击则命中。
这个模型的好处是它和渲染完全解耦。你写控制台版本时用字符画洞,写图形版本时贴位图,底层逻辑不用改。期末答辩时,老师问“你的游戏逻辑和界面是怎么分离的”,你能回答“逻辑层只维护状态和时间戳,界面层负责把状态画出来”,这比“我用了很多 if”值钱得多。
常见路线对比如下:
| 实现路线 | 上手难度 | 适合场景 | 需要额外依赖 |
|---|---|---|---|
| 控制台 + 键盘输入 | 低 | 快速交差、验证逻辑 | 无 |
| Win32 API + GDI | 中 | Windows 课设、学消息循环 | 无 |
| EGE 图形库 | 中低 | 想有图又不想碰 Win32 | 需要下载安装 EGE |
| Qt / SFML | 偏高 | 想要完整工程结构 | 需要配置环境 |
课程设计偏向“能跑、能演示、代码能讲清”,控制台版或 EGE 版是性价比最高的两档。下面按控制台版为主线,因为它的代码量刚好够一篇报告讲透,又不至于让答辩变成 IDE 排错现场。
2.2 状态枚举与地鼠对象的数据结构
地鼠对象最少需要三个字段:洞的编号、当前状态、状态剩余时间。多余的字段不要加,课设报告里最容易画蛇添足的是把“地鼠的 x/y 坐标”塞进每个地鼠,但控制台版根本没有坐标映射,这个字段纯属给自己找麻烦。
enum class MoleState : int { EMPTY = 0, // 洞为空 RISING = 1, // 正在冒头(可被击中) HIDDEN = 2 // 正在缩回(不可被击中) }; struct Mole { int holeId; // 洞编号 0~8 MoleState state; // 当前状态 int remainTick; // 当前状态剩余 tick 数 bool alive; // 该洞是否允许出鼠 };逻辑说明:remainTick是核心字段,每过一个时间片减 1,减到 0 就切换状态。alive用于关卡设计——比如第 1 关只有 5 个洞会出鼠,第 2 关 9 个都出。
参数说明:holeId从 0 开始编号而不是 1,方便直接用数组下标访问。MoleState用枚举类而不是int,防止在 switch 里漏掉分支。如果有 20 行以上的 switch 语句,建议打开编译器警告/W4,漏分支时会有提示。
2.3 随机出鼠的数学期望与计分参数
随机出鼠不能用均匀分布的随机整数直接决定“哪只鼠出来”,否则会出现连续很多 tick 没有鼠、玩家干等的情况。常见做法是:每个 tick 对每个alive == true的洞,以一个小概率p独立判定是否进入RISING状态。
这里有个可以写进报告的计算:若 9 个洞,每 tick 每洞出鼠概率 2%,则单 tick 内至少有一只新鼠出现的概率是1 - (1-0.02)^9 ≈ 16.6%。如果 tick 间隔 200ms,5 秒内至少刷出一只鼠的概率约 99%。把这个式子写进课设文档“实验参数设计”一节,答辩时能直接回应“为什么选这个概率”。
计分模型建议拆成基础分和惩罚分:击中得 100 分,鼠自然缩回扣 50 分,击中缩回中的鼠不得分。惩罚分作用是防止玩家乱点。注意扣分下限不能为负,否则会出现负分排行榜。
3. C++ 打地鼠最小可运行实现:控制台版
3.1 主循环与 tick 推进
打地鼠的循环不是while(true)里死转,而是按固定频率推进时间片。控制台版用Sleep(200)近似 5Hz 逻辑帧;图形版会用定时器或GetTickCount64计算真实间隔。核心是 tick 函数:
void Tick(vector<Mole>& moles, int elapsedMs) { const int tickMs = 200; // 一个时间片 200ms for (auto& mole : moles) { if (!mole.alive) continue; mole.remainTick -= elapsedMs; if (mole.remainTick <= 0) { switch (mole.state) { case MoleState::EMPTY: // 进入出现状态,持续 3~5 个 tick if (RandomHit(2)) { // 单洞单 tick 2% 概率 mole.state = MoleState::RISING; mole.remainTick = RandomRange(600, 1000); } else { mole.remainTick = tickMs; } break; case MoleState::RISING: // 自然消失,扣分 mole.state = MoleState::HIDDEN; mole.remainTick = 200; score -= 50; if (score < 0) score = 0; break; case MoleState::HIDDEN: mole.state = MoleState::EMPTY; mole.remainTick = tickMs; break; } } } }逻辑说明:这个函数的推进粒度是毫秒,但状态的停留时间用 tick 数换算。RandomHit(2)表示 2% 概率。RandomRange(600, 1000)让出现持续 3~5 个 tick,玩家才有反应时间。
参数说明:elapsedMs是本次循环实际经过的毫秒,不要假设每次都恰好是 200。如果某次循环卡了 400ms,那么所有remainTick会一起多扣 200ms,游戏整体变快——这也是为什么不要用计数器--来做倒计时。
3.2 命中判定与输入解析
控制台版的输入是玩家输入洞编号 0~8 加回车。输入不能直接修改状态,否则会出现“输入时游戏暂停”的错觉:玩家等鼠出来时按了半天键都没反应,因为代码在阻塞读键盘。常见做法是开一个单独线程收输入,或者用_kbhit()在循环尾部非阻塞检查。
void HitTest(vector<Mole>& moles, int targetHole) { if (targetHole < 0 || targetHole >= moles.size()) return; Mole& mole = moles[targetHole]; if (mole.state == MoleState::RISING) { score += 100; mole.state = MoleState::HIDDEN; mole.remainTick = 200; hitCount++; printf("hit hole %d +100\n", targetHole); } else { // 点空或点中不在可打状态的洞,轻惩罚 score -= 10; if (score < 0) score = 0; } }逻辑说明:命中只对RISING状态有效。注意这里把命中后的老鼠也置为HIDDEN,而不是EMPTY——它还要缩回,如果直接消失会显得很突然。hitCount用于统计命中率,供结束画面展示。
参数说明:点空扣 10 分是给乱点流的约束。如果你希望游戏更休闲,把这条删掉;如果更硬核,加到 30。注意惩罚分要低于命中奖励,否则玩家会因手抖直接负分。
3.3 渲染层与逻辑层的边界
控制台渲染就是按行打印。9 个洞排成 3x3,打印每个洞的状态。这里最容易被忽略的是清屏方式:system("cls")在控制台版里能用,但期末报告里最好写“调用控制台 API 清屏”,因为你一旦切到 Win32 或 EGE,system("cls")就失效了。
void Render(const vector<Mole>& moles) { system("cls"); printf("score: %d\n", score); for (int row = 0; row < 3; ++row) { for (int col = 0; col < 3; ++col) { int idx = row * 3 + col; char mark = '.'; if (moles[idx].state == MoleState::RISING) mark = 'M'; else if (moles[idx].state == MoleState::HIDDEN) mark = 'm'; printf(" %c ", mark); // 9 个洞字符画 } printf("\n"); } }逻辑说明:M表示可打,m表示正在缩回,.表示空。这个输出是给逻辑调试用的,别花时间调字符画对齐,答辩不看这个。
参数说明:用M/m区分状态,调试时一眼能看出状态切换是否正常。如果出现m持续两个 tick 以上,说明HIDDEN的remainTick没有正确递减。
4. 从课程设计到工程化:代码组织与常见坑
4.1 把 main 函数里的代码拆成三个文件
课设源码最常见的扣分点是几百行全堆在main.cpp里。不是老师要求你懂架构,而是拆文件后,报告里能写“本系统分为逻辑层、渲染层和工具层”。拆分也很简单,不需要引入类继承:
// game_logic.h #pragma once #include <vector> struct Mole; void Tick(std::vector<Mole>& moles, int elapsedMs); void HitTest(std::vector<Mole>& moles, int targetHole); int GetScore(); bool IsGameOver(int timeLimitMs); // renderer.h #pragma once #include <vector> struct Mole; void Render(const std::vector<Mole>& moles, int score); // main.cpp #include "game_logic.h" #include "renderer.h"逻辑说明:struct Mole可以放在单独的mole.h里,让两个头文件都能引用。这里的#pragma once是比#ifndef更现代的头文件保护写法,MSVC 和 GCC 都支持,课设环境不会踩坑。
参数说明:game_logic.h里不暴露内部状态,只暴露操作函数,这样答辩时被问“你的全局变量怎么管理”,你答“只有模块内部持有,外部通过接口访问”,这句话能为报告加分不少。GetScore()返回int而不是引用,避免外部直接改分。
4.2 随机数的坑:rand 的分布偏置
C++ 课设里用rand() % 100 < 2判断 2% 概率,表面看没问题,但rand()的低位随机性不好,尤其当模数不是 2 的幂时,分布会有偏差。游戏里偏差不明显,但期末报告的“系统测试”章节如果你写“随机数验证:运行 10000 次统计概率”,用rand大概率得到 1.85%~2.15% 之间的数据,这本身没问题;可一旦评审老师要求你解释误差,你得能说出来。
更稳的是 C++11 的随机数库:
#include <random> static std::mt19937 rng(std::random_device{}()); static std::uniform_int_distribution<int> dist(1, 100); bool RandomHit(int percent) { return dist(rng) <= percent; } int RandomRange(int minMs, int maxMs) { std::uniform_int_distribution<int> d(minMs, maxMs); return d(rng); }逻辑说明:mt19937是梅森旋转算法,周期够长,课设场景完全够用。random_device用来播种,每次运行序列不同。
参数说明:dist(rng)生成 1~100 的均匀整数。RandomHit(2)返回 true 的概率是 2%。注意RandomRange每次都构造新分布对象,性能略差,但打地鼠不是高频调用,无所谓。
4.3 编译环境与 Visual C++ 运行库的课设常见事故
课设验收时最常见的事故是:代码在自己电脑上跑得好好的,插上老师电脑就弹“缺少 VCRUNTIME140.dll”。这不是代码问题,是运行库没装。分发源码一般不需要打包运行库,但如果你交的是可执行文件,最好附一句“需要安装 Visual C++ Redistributable”。
VSCode 配置 C/C++ 环境时还有个高频坑:选了 “g++” 编译器,但课设要求用 “Visual Studio”,两者对for (int i = 0; i < n; i++)这种循环变量作用域的处理不同,代码可能一边编译过一边报错。统一用-std=c++17能缓解;另外确认自己用的不是cl和g++混着编译,链接时会出unresolved external symbol这类难查的错。
# VSCode tasks.json 中典型编译命令 g++ -std=c++17 -Wall -Wextra -O2 main.cpp game_logic.cpp -o whack_mole.exe逻辑说明:-Wall -Wextra开启常见警告,课程设计期间建议保留。-O2优化可加可不加,对这个小游戏没感知。
参数说明:如果你在 Windows 上用 MSVC,命令换成cl /std:c++17 /W4 main.cpp game_logic.cpp /Fe:whack_mole.exe。两组命令二选一即可,别在声明“我用 VSCode”的同时在答案里写两套。
4.4 关卡与排行榜:加这两个功能胜过加十个洞
课程设计要的是功能区分度。打地鼠的核心机制是“状态机 + 计分”,你要扩展,最顺手的两个方向是:
- 关卡参数表:第 N 关的
appearProb、stayTimeMs、holeCount写成一个数组,加载后按关卡切换。 - 排行榜:本地保存最高分到文件,启动时读取。
这俩都用不了多少代码,但能让报告“系统功能”最少多写三行。切忌去加什么粒子特效、音效线程,课设评审看的是完整度而非炫技。
struct LevelConfig { int holeCount; int appearPercent; // 单洞单 tick 出鼠概率,单位 % int stayMinMs; int stayMaxMs; int gameDurationSec; }; static const LevelConfig kLevels[] = { { 5, 2, 600, 1000, 30 }, // 新手关:洞少 { 9, 3, 500, 900, 45 }, // 进阶关:洞多且快 { 9, 5, 400, 700, 60 } // 挑战关:概率高,停留短 };逻辑说明:kLevels是只读数组,按currentLevel取配置。holeCount变化时要重新创建Mole数组,或在创建时多建几个但把alive置 false。
参数说明:appearPercent和stayMinMs/stayMaxMs是调平衡的核心旋钮。自己试玩时,如果一关 30 秒能打中 15 次以上,就把appearPercent降 1;如果一次都打不中,加 1。
5. 用确定性种子验证随机逻辑:一次可复现的测试
打地鼠这类随机游戏最难受的是 bug 无法复现:上一次运行第 10 秒出 bug,重跑就没了。解决办法是给随机数生成器一个可指定的种子,让“这次运行”可以被重新跑一遍。
void SetRandomSeed(unsigned seed) { rng.seed(seed); } void RunTest(int seed, int ticks) { SetRandomSeed(seed); vector<Mole> moles = CreateMoles(9); int score = 0; for (int t = 0; t < ticks; ++t) { Tick(moles, 200); // 模拟一个脚本化的输入序列 if (t % 7 == 0) HitTest(moles, t % 9); } printf("seed=%d totalScore=%d\n", seed, score); }逻辑说明:SetRandomSeed暴露给测试代码,正常游戏入口调用SetRandomSeed(random_device{}())。测试脚本固定输入序列,这样 seed 相同则结果必然相同。
参数说明:输入序列写t % 7 == 0是模拟玩家每 7 个 tick 打一次固定的洞。你还可以加一个断言,比如assert(score >= 100),如果在某个 seed 下断言失败,把这个 seed 写死在测试里做回归。这个方法是正规游戏团队的每日构建做法,但课设报告写成“采用了确定性随机数技术保证可测试性”,已经超出多数同学的完成度。
最后给两个落地时立刻能用的验证命令:./whack_mole.exe --seed 42 --ticks 500跑一次固定场景,再正常不带参数跑一次。两次输出如果固定场景一致,说明逻辑层没有未初始化变量;如果两次不一致,顺着score的变化打断点,断点位置设在Tick()入口,观察remainTick的递减路径。调试器停在第一个 tick 时,把断点条件设为mole.state == MoleState::RISING,此后每次切换都能看到完整的 200ms 到 1000ms 停留区间,问题会在十分钟内浮出水面。
本文还有配套的精品资源,点击获取