简介:这是一份面向高校计算机专业学生的C++课程设计完整项目包,主题为消除数字小游戏,适合刚接触游戏开发、需要完成课程作业或想通过实战巩固C++语法的初学者。项目围绕游戏区域数字布局、消除判定、得分记录与界面刷新等核心机制展开,涉及数组与容器选型、搜索算法、图形库调用以及调试测试等环节,可作为理解软件开发完整流程的练手案例。压缩包共59个文件,约52.99MB,包含cpp与h源码、sln与vcxproj工程文件、exe可执行程序,以及jpg图片、mp3音效等素材,另有pdb、obj、tlog等编译中间与日志文件,目录按Debug、Release、music、picture等模块组织,结构清晰便于对照阅读。目前已有204人学习下载。读者可借此获得可直接编译运行的工程、完整的游戏逻辑实现与资源素材,并参考其代码组织方式与排错思路,快速搭建自己的课程设计框架。
1. 从一份 C++ 课程设计说起:消除数字小游戏到底在练什么
很多同学拿到「C++课程设计基于C++的消除数字小游戏源代码」这个题目时,第一反应是去搜一份现成源码交差。但真正做过答辩的人都知道,老师翻两页代码就会问:你的数据结构怎么设计的?消除判定为什么这么写?随机数为什么每次跑出来一样?这些问题答不上来,源码再长也白搭。消除数字小游戏看起来简单——一个二维网格,点两个相邻且数字相同的格子就消掉,上面的格子掉下来,再补新数字——但它几乎把 C++ 课程设计要考的核心点全串了一遍:二维数组或容器、结构体、函数拆分、随机数、循环与条件、控制台输入输出,甚至还能延伸到文件存档和排行榜。它适合刚学完 C++ 基础语法、想找一个能跑起来又有东西可讲的项目练手的人,也适合要交课程设计、需要一份自己能讲清楚逻辑的源代码的同学。下面我按自己带学生做这类题目的顺序,把选型、实现、参数和踩过的坑一次讲透。
2. 消除数字小游戏的骨架:数据结构与消除规则怎么定
2.1 用二维数组还是 vector:先看棋盘要不要动态变
消除类游戏最核心的载体就是棋盘。课程设计里最常见的两种做法:一种是固定大小的二维数组int board[ROW][COL],另一种是vector<vector<int>>。如果你只是做一个 8×8 或 10×10 的固定棋盘,二维数组完全够用,代码直观,调试时在 IDE 里能直接看到内存布局,答辩也好讲。但如果你想让棋盘大小可配置,或者后面想加「关卡制」让棋盘逐关变大,那就该用 vector,因为它的行数和列数可以在运行时决定。
我一般会这样定义棋盘和格子状态:
#include <vector> #include <iostream> using namespace std; const int ROW = 8; // 棋盘行数 const int COL = 8; // 棋盘列数 const int EMPTY = 0; // 空格标记,0 表示这个位置已经被消除、等待下落填充 // 用 vector 的好处:行列可配置,后续扩展关卡不用改结构 vector<vector<int>> board(ROW, vector<int>(COL, 0)); // 初始化棋盘:每个格子随机填 1~5 的数字 void initBoard() { for (int i = 0; i < ROW; ++i) for (int j = 0; j < COL; ++j) board[i][j] = rand() % 5 + 1; // 1~5 共 5 种数字 }这段代码里,EMPTY = 0是一个约定:棋盘上合法数字是 1 到 5,0 专门留给「空位」。这个约定非常关键,因为消除之后格子不会立刻消失,而是先变成 0,等下落逻辑处理完再补新数字。参数上,rand() % 5 + 1决定了数字种类数,5 种是比较平衡的选择——种类太少容易连消,种类太多玩家很难找到可消的对子。你可以改成 4 种降低难度,或 6 种提高难度。
2.2 消除判定:相邻且相同,别写成全盘扫描
消除规则本身不复杂:玩家选两个格子,如果它们相邻(上下左右,不含斜角)且数字相同且都不是 0,就消除。但很多初学者会把它写成「遍历全盘找所有相同的」,那就跑偏了。正确做法是把判定收敛到一个函数里:
// 判断两个坐标是否相邻(只算上下左右) bool isAdjacent(int r1, int c1, int r2, int c2) { int dr = abs(r1 - r2); int dc = abs(c1 - c2); return (dr + dc) == 1; // 曼哈顿距离为 1 才是四邻接 } // 尝试消除,成功返回 true bool tryEliminate(int r1, int c1, int r2, int c2) { if (board[r1][c1] == EMPTY || board[r2][c2] == EMPTY) return false; if (board[r1][c1] != board[r2][c2]) return false; if (!isAdjacent(r1, c1, r2, c2)) return false; board[r1][c1] = EMPTY; board[r2][c2] = EMPTY; return true; }isAdjacent用曼哈顿距离判断,(dr + dc) == 1正好覆盖上下左右四种情况,斜角因为dr + dc == 2被排除。这个写法比写四个 if 判断干净得多,答辩时也能体现你懂坐标几何。tryEliminate里三个判断的顺序有讲究:先判空、再判数字相等、最后判相邻。为什么先判空?因为空格是 0,如果两个空格相邻,数字相等(都是 0),不加空判断就会误消。
2.3 下落与补充:消除之后棋盘怎么「自愈」
消除只是第一步,消除后上方格子要往下掉,顶部空出来的位置要补新数字。这是整个游戏里最容易写错的地方。下落逻辑必须从下往上处理,否则会出现「跳过」问题:
// 处理某一列的下落:把非空格子压到底部 void dropColumn(int col) { int writeRow = ROW - 1; // 从最底行开始写 for (int r = ROW - 1; r >= 0; --r) { if (board[r][col] != EMPTY) { board[writeRow][col] = board[r][col]; if (writeRow != r) board[r][col] = EMPTY; --writeRow; } } // 剩余顶部空位补新数字 for (int r = writeRow; r >= 0; --r) board[r][col] = rand() % 5 + 1; } void applyGravity() { for (int c = 0; c < COL; ++c) dropColumn(c); }dropColumn用了一个「写指针」writeRow,从底部往上扫描,遇到非空就写到writeRow位置,然后writeRow上移。这样一趟扫描就能完成压实,时间复杂度 O(ROW)。补新数字时只补writeRow以上的位置,不会覆盖已有数字。这里有个血泪经验:如果你从下往上遍历时直接交换board[r][col]和board[r+1][col],遇到连续多个空格会出错,因为一次交换只能移动一格,而写指针法一次到位。
3. 把游戏跑起来:控制台交互、随机数与主循环
3.1 控制台输入输出:坐标怎么读才不别扭
课程设计大多跑在控制台里,输入方式直接影响体验。常见做法是让玩家输入两个坐标,比如3 4 3 5表示选 (3,4) 和 (3,5) 两个格子。读取时用cin即可,但要做输入校验,防止玩家输入越界坐标导致数组越界崩溃:
void printBoard() { cout << " "; for (int c = 0; c < COL; ++c) cout << c << " "; cout << "\n"; for (int r = 0; r < ROW; ++r) { cout << r << " "; for (int c = 0; c < COL; ++c) { if (board[r][c] == EMPTY) cout << ". "; else cout << board[r][c] << " "; } cout << "\n"; } } bool readMove(int &r1, int &c1, int &r2, int &c2) { cout << "输入两个格子坐标(行 列 行 列): "; if (!(cin >> r1 >> c1 >> r2 >> c2)) { cin.clear(); cin.ignore(10000, '\n'); return false; } if (r1 < 0 || r1 >= ROW || c1 < 0 || c1 >= COL || r2 < 0 || r2 >= ROW || c2 < 0 || c2 >= COL) { cout << "坐标越界,请重新输入\n"; return false; } return true; }printBoard打印时用.表示空格,比打印 0 更直观。readMove里cin.clear()和cin.ignore()是处理非法输入的标准组合——如果玩家输入了字母,cin会进入失败状态,不清除的话后面所有读取都会失败,这是控制台程序最常见的翻车点之一。坐标校验必须做,否则board[r1][c1]直接越界,程序可能崩溃或读到垃圾值。
3.2 随机数:为什么你的棋盘每次开局都一样
rand()如果不播种,每次运行程序生成的序列完全相同,棋盘自然一模一样。这是课程设计答辩时老师最爱问的点。正确做法是用srand(time(nullptr))播种,而且只需要在main开头播一次:
#include <ctime> #include <cstdlib> int main() { srand((unsigned)time(nullptr)); // 用当前时间做种子,只播一次 initBoard(); // ... 主循环 return 0; }注意srand只能调用一次,如果每次生成随机数前都调用,同一秒内种子相同,反而更不随机。另外rand()的范围是 0 到RAND_MAX,取模% 5会有轻微偏差,但对课程设计来说完全够用。如果你想要更均匀的分布,可以用 C++11 的mt19937配合uniform_int_distribution,但那是加分项,不是必须。
3.3 主循环与计分:一局游戏怎么收尾
主循环负责「打印棋盘 → 读输入 → 判定消除 → 下落补充 → 判断是否还有可消对子」这个流程。计分可以简单点,每消除一对加 10 分。判断游戏结束有两种常见标准:一种是棋盘满了且没有相邻相同数字,另一种是设置步数上限。课程设计里推荐用前者,逻辑更完整:
bool hasMove() { for (int r = 0; r < ROW; ++r) for (int c = 0; c < COL; ++c) { if (board[r][c] == EMPTY) continue; // 只检查右边和下边,避免重复 if (c + 1 < COL && board[r][c] == board[r][c + 1]) return true; if (r + 1 < ROW && board[r][c] == board[r + 1][c]) return true; } return false; } int main() { srand((unsigned)time(nullptr)); initBoard(); int score = 0; while (hasMove()) { printBoard(); cout << "当前得分: " << score << "\n"; int r1, c1, r2, c2; if (!readMove(r1, c1, r2, c2)) continue; if (tryEliminate(r1, c1, r2, c2)) { score += 10; applyGravity(); } else { cout << "这两个格子不能消除\n"; } } cout << "游戏结束,最终得分: " << score << "\n"; return 0; }hasMove只检查右边和下边,因为左边和上边在之前的遍历中已经检查过了,这样能把复杂度从 O(ROW×COL×4) 降到 O(ROW×COL)。主循环用while (hasMove())控制,一旦没有可消对子就退出并结算。这个结构清晰,答辩时能一眼看出游戏状态机。
4. 避坑与排查:课程设计里最容易翻车的 5 个点
4.1 现象:程序一运行就崩溃,提示数组越界
原因:玩家输入的坐标没有校验,或者下落逻辑里writeRow越界。解决:所有访问board[r][c]的地方,前面必须保证r在[0, ROW)、c在[0, COL)。readMove里做一次校验,dropColumn里writeRow初始为ROW - 1,只会递减不会越界,但补新数字的循环条件r >= 0要写对。
4.2 现象:消除后格子没有掉下来,或者掉下来顺序乱了
原因:下落时从上往下遍历,导致上面的格子被跳过。解决:必须从下往上扫描,用写指针法。如果你用交换法,记住一次交换只移动一格,连续空格会出错。测试时可以在applyGravity后打印棋盘,观察每一列是否压实。
4.3 现象:每次开局棋盘完全一样,随机数不随机
原因:没有调用srand,或者srand放在了循环里。解决:在main开头调用一次srand((unsigned)time(nullptr)),并且确保<ctime>和<cstdlib>都包含了。如果用的是 Visual Studio,还要注意time返回类型转换,写(unsigned)time(nullptr)避免警告。
4.4 现象:输入字母后程序疯狂刷屏,无法继续
原因:cin进入失败状态后没有清除,后续读取全部失败,循环空转。解决:在读取失败时执行cin.clear()清除错误标志,再用cin.ignore(10000, '\n')丢弃缓冲区里的非法字符。这个组合要背下来,控制台程序里几乎必用。
4.5 现象:游戏结束后还能继续输入,或者提前结束
原因:hasMove判断逻辑写错,比如把空格也当成可消对子,或者只检查了部分方向。解决:hasMove里先continue跳过空格,再只检查右边和下边。测试时可以手动构造一个没有可消对子的棋盘,看程序是否正确退出。
5. 进阶技巧:存档、排行榜与让代码更像「课程设计」
5.1 用文件存档把一局游戏存下来
课程设计如果只做到控制台消除,分数可能刚够及格。加一个存档功能,立刻上一个档次。思路很简单:把棋盘和分数写进文本文件,下次启动时读回来。文件格式用「第一行分数,后面每行是棋盘一行」:
#include <fstream> void saveGame(const string& filename, int score) { ofstream fout(filename); if (!fout) { cout << "存档失败\n"; return; } fout << score << "\n"; for (int r = 0; r < ROW; ++r) { for (int c = 0; c < COL; ++c) { fout << board[r][c]; if (c + 1 < COL) fout << " "; } fout << "\n"; } fout.close(); } bool loadGame(const string& filename, int& score) { ifstream fin(filename); if (!fin) return false; fin >> score; for (int r = 0; r < ROW; ++r) for (int c = 0; c < COL; ++c) fin >> board[r][c]; return true; }saveGame把分数写在第一行,棋盘按行写,数字之间用空格分隔。loadGame按同样格式读回。注意读取时如果文件不存在,ifstream会失败,直接返回 false,主程序里可以选择「新开一局」。这个功能代码量不大,但答辩时能讲「数据持久化」,很加分。
5.2 排行榜:排序算法终于用上了
排行榜本质就是把分数存进数组然后排序。课程设计里老师常问「你用了什么排序」,这时候冒泡排序、插入排序都能派上用场。我一般用插入排序,因为分数是逐个插入的,插入排序天然适合:
#include <algorithm> void addScore(vector<int>& scores, int newScore) { scores.push_back(newScore); // 插入排序:从后往前,把新分数放到正确位置 int i = scores.size() - 1; while (i > 0 && scores[i] > scores[i - 1]) { swap(scores[i], scores[i - 1]); --i; } if (scores.size() > 10) scores.resize(10); // 只保留前 10 }scores始终保持降序,新分数从尾部插入,如果比前一个大就交换,直到找到位置。resize(10)保证排行榜只留前十。这个逻辑简单,但能体现你对排序的理解。如果想用std::sort,写sort(scores.begin(), scores.end(), greater<int>())也行,但答辩时老师可能追问排序原理,所以手写一个更稳。
5.3 让代码结构更清晰:函数拆分与头文件
最后说一个习惯问题。很多同学的课程设计所有代码堆在main里,几百行一气呵成,能跑但没法看。我一般会把棋盘操作、输入输出、存档分成不同函数,甚至拆成board.h、board.cpp、main.cpp。这样答辩时你可以指着文件说「这是数据层,这是交互层」,老师印象分直接拉满。另外,变量命名别用a、b、c,用board、score、row、col,代码可读性上来了,你自己调试也快。
我带了这么多届课程设计,最深的教训就是:别一上来就写代码,先把棋盘怎么表示、消除怎么判定、下落怎么处理这三件事在纸上画清楚,再动手。否则写到一半发现数据结构选错,返工的时间够你重写三遍。希望帮到你。
本文还有配套的精品资源,点击获取