C++课程设计实战:打地鼠游戏的状态机与实现
简介这是面向高校C课程设计和期末大作业的实用项目资源提供基于Qt框架的打地鼠游戏完整工程。代码涵盖随机生成地鼠、鼠标点击判定、计时计分、胜利与结束界面等核心模块既可直接编译运行作为验收成果也适合作为学习游戏循环、界面布局、信号槽机制和对象管理的样例。压缩包中共有60个文件包括主程序源文件、头文件、界面定义文件、工程配置文件和大量游戏素材图片整体大小约4.97MB目录中同时保留多个开发版本方便对比各阶段改动。截止目前已有1766人下载使用许多学生参考本工程完成了类似课程任务。查看代码后可以快速掌握打地鼠游戏的实现思路也能根据需求自行增加关卡、音效或最高分记录让期末设计更完整、更有亮点。1. 打地鼠游戏源码的打开方式先理解状态再谈代码一门 C 课设拿到手很多人下意识先找 main 函数在哪再把代码读完。但打地鼠这类小游戏真正的分水岭不在图形而在“状态机”和“输入响应”地鼠什么时候冒头、什么时候缩回、玩家点中的是哪个洞、分数在哪个时间片结算。你把这四件事理清了源码读起来就只剩下查漏补缺理不清代码再多也只是往 onMouseClick 里堆逻辑。这篇以课程设计常见难度为前提讲清楚打地鼠游戏的模型怎么建、循环怎么写、测试怎么做也顺带覆盖你用 VS 或 VSCode 编译时可能遇到的运行库问题。适合正在做期末大作业的在校生也适合想快速捡起 C 小游戏骨架的在职开发者。2. 打地鼠游戏的模型设计从需求到数据结构2.1 打地鼠不是图形问题是时间片问题很多课程设计报告把打地鼠写成“一个图形化游戏”但代码核心其实是一个离散时间模型。游戏世界可以抽象成一张定时刷新的表每个洞hole在某一时刻处于“空、出现、停留、消失”四种状态之一玩家在特定时间窗口内点击则命中。这个模型的好处是它和渲染完全解耦。你写控制台版本时用字符画洞写图形版本时贴位图底层逻辑不用改。期末答辩时老师问“你的游戏逻辑和界面是怎么分离的”你能回答“逻辑层只维护状态和时间戳界面层负责把状态画出来”这比“我用了很多 if”值钱得多。常见路线对比如下实现路线上手难度适合场景需要额外依赖控制台 键盘输入低快速交差、验证逻辑无Win32 API GDI中Windows 课设、学消息循环无EGE 图形库中低想有图又不想碰 Win32需要下载安装 EGEQt / 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 间隔 200ms5 秒内至少刷出一只鼠的概率约 99%。把这个式子写进课设文档“实验参数设计”一节答辩时能直接回应“为什么选这个概率”。计分模型建议拆成基础分和惩罚分击中得 100 分鼠自然缩回扣 50 分击中缩回中的鼠不得分。惩罚分作用是防止玩家乱点。注意扣分下限不能为负否则会出现负分排行榜。3. C 打地鼠最小可运行实现控制台版3.1 主循环与 tick 推进打地鼠的循环不是while(true)里死转而是按固定频率推进时间片。控制台版用Sleep(200)近似 5Hz 逻辑帧图形版会用定时器或GetTickCount64计算真实间隔。核心是 tick 函数void Tick(vectorMole 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(vectorMole 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 或 EGEsystem(cls)就失效了。void Render(const vectorMole 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::vectorMole moles, int elapsedMs); void HitTest(std::vectorMole moles, int targetHole); int GetScore(); bool IsGameOver(int timeLimitMs); // renderer.h #pragma once #include vector struct Mole; void Render(const std::vectorMole 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% 之间的数据这本身没问题可一旦评审老师要求你解释误差你得能说出来。更稳的是 C11 的随机数库#include random static std::mt19937 rng(std::random_device{}()); static std::uniform_int_distributionint dist(1, 100); bool RandomHit(int percent) { return dist(rng) percent; } int RandomRange(int minMs, int maxMs) { std::uniform_int_distributionint 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)这种循环变量作用域的处理不同代码可能一边编译过一边报错。统一用-stdc17能缓解另外确认自己用的不是cl和g混着编译链接时会出unresolved external symbol这类难查的错。# VSCode tasks.json 中典型编译命令 g -stdc17 -Wall -Wextra -O2 main.cpp game_logic.cpp -o whack_mole.exe逻辑说明-Wall -Wextra开启常见警告课程设计期间建议保留。-O2优化可加可不加对这个小游戏没感知。参数说明如果你在 Windows 上用 MSVC命令换成cl /std:c17 /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); vectorMole 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 停留区间问题会在十分钟内浮出水面。本文还有配套的精品资源点击获取