简介:这是一款基于 C++ 与 EasyX 图形库实现的数独游戏课程设计项目,面向希望入门游戏编程或掌握图形界面开发的学习者,也适合作为毕业设计、课程设计或大作业参考。压缩包共包含 47 个文件,涵盖 .cpp 源代码、Visual Studio 工程与解决方案文件、可直接运行的 .exe 程序、多张界面截图、说明文档以及编译过程生成的日志和调试文件,整体大小仅 7.03MB,方便快速下载和本地验证。功能方面,程序实现了用户名录入、关卡选择与文档导入、计时计分、鼠标点击填数、九宫格高亮显示、暂停/重开/退出、显示答案以及玩家得分排行榜等完整模块,能够帮助读者将 EasyX 图形编程、鼠标事件响应、状态管理和文件读写等知识点串联起来。项目默认初局仅保留一个空格,便于快速调试验证整体流程,修改 MapMain 矩阵即可设置新的初始关卡;同时附带 README 与系统功能图,降低了上手门槛,也便于拆解各功能区设计。目前已有 347 人学习下载,适合需要完整可运行项目作为课程设计参考的开发者。
1. 为什么课程设计选"数独+EasyX+C++"这套组合
数独课程设计是 C++ 图形化入门里被选烂但没人认真做的一道题:用 EasyX 画个 9x9 网格,填数字,判输赢。但真正能拿高分的版本和只跑通演示的版本之间,差的不是界面花哨与否,而是底层算法和交互逻辑的完整度。下面把课程设计里从盘面生成、难度控制、渲染绘制到鼠标交互的整套环节讲清楚,给出能直接抄的代码和参数设置,最后收在计时、提示、存档与调试技巧上。适合正在做 C++ 课程设计、想用 EasyX 做图形界面、又不满足于“画格子填数字”的初学者;有五年以上经验的人也能从唯一解验证和冲突标记的数据模型里找出值得对比的技术点。
2. 数独生成与唯一解验证:从回溯填盘到按难度挖洞
2.1 为什么纯随机填数走不通
把 1 到 9 随机往 81 个格子里塞,然后检查每行每列每宫是否有重复,这是新手最容易想到的思路。问题在于合法终盘占所有排列的比例极低,随机填满一张盘后基本不可能直接合法,撞出一张合法盘的计算成本高到不可接受。正确的做法是反着来:先用回溯生成一张完整合法终盘,再从这个终盘里挖掉若干格子形成题目。这样生成环节不用校验“是否有解”,因为完整盘已经保证可解,剩下的动作只围绕“解是否唯一”来做。
课程设计答辩时经常被追问“为什么随机生成不行”,用刚才这句话回答就够了。如果还要把回溯原理讲清楚,就落在深度优先搜索加约束剪枝上:每个空格按行、列、宫的约束排除候选数字,走不通就回退重选,最坏情况是 O(81^9),但实际剪枝后往往在几毫秒内完成。
2.2 用回溯生成完整终盘
生成函数从第 0 格递推到第 80 格,遇到已有数字的格子直接跳过;空格则按随机顺序尝试 1 到 9,某个数字满足三个约束就填下去,递归失败再回退到 0。为了让每次运行得到不同的终盘,候选数字序列要洗牌,不然永远是同一张盘。
// 判断 (row, col) 能否填 val bool isPlaceable(const int board[9][9], int row, int col, int val) { for (int i = 0; i < 9; i++) { if (board[row][i] == val) return false; // 行重复 if (board[i][col] == val) return false; // 列重复 } int br = row / 3 * 3, bc = col / 3 * 3; for (int i = br; i < br + 3; i++) for (int j = bc; j < bc + 3; j++) if (board[i][j] == val) return false; // 宫重复 return true; } bool fillBoard(int board[9][9], int pos) { if (pos == 81) return true; int row = pos / 9, col = pos % 9; if (board[row][col] != 0) return fillBoard(board, pos + 1); int nums[9] = {1, 2, 3, 4, 5, 6, 7, 8, 9}; for (int i = 8; i > 0; i--) { // Fisher-Yates 洗牌 int j = rand() % (i + 1); swap(nums[i], nums[j]); } for (int k = 0; k < 9; k++) { board[row][col] = nums[k]; if (isPlaceable(board, row, col, nums[k]) && fillBoard(board, pos + 1)) return true; } board[row][col] = 0; // 全部失败,回溯 return false; }isPlaceable每次做 9 次行比较、9 次列比较和 8 次宫比较,共 26 次比较。一个盘面要回溯几百到几千次,总体在几十毫秒内。fillBoard里先洗牌再尝试,洗牌的位置放在尝试循环之前,保证每次递归层的候选顺序都不同。
参数上有两个可以调的点:候选数字顺序决定生成盘面的随机性;pos遍历方向决定递归树形状。按 row-major 从左上到右下是最直观的,改成按宫遍历或绕中心螺旋遍历都不是必须的,课程设计做到 row-major 就够了。
2.3 挖洞控制难度并验证唯一解
完整终盘挖掉格子后要重新验证:剩余空白是否能推出唯一的终盘。实现上是在挖洞后的盘面上做多解计数,只要计数到 2 立刻退出,证明“不止一个解”,把刚才挖空的格子回填。只有计数保持为 1 才说明解唯一,可以保留这个空位。
int countSolutions(int board[9][9], int limit) { for (int r = 0; r < 9; r++) { for (int c = 0; c < 9; c++) { if (board[r][c] == 0) { int cnt = 0; for (int v = 1; v <= 9; v++) { if (isPlaceable(board, r, c, v)) { board[r][c] = v; cnt += countSolutions(board, limit); board[r][c] = 0; if (cnt >= limit) return cnt; // 提前终止 } } return 0; // 当前位置没有可填数字 } } } return 1; // 81 格全部填满,计一个完整解 }这里limit传 2 时,上面代码在第二次命中完整解的位置就会开始逐层返回并被上层发现cnt >= limit,从而快速结束递归。相比跑去找出所有解再数一遍,这种提前终止能节省大量时间。挖洞时每次随机选一个格子挖,然后验证,失败就回填把格子恢复,继续尝试下一个候选格。
挖洞数量的经验值直接影响难度感知:
| 难度 | 挖洞目标 | 实际剩余空格区间 | 生成耗时参考 |
|---|---|---|---|
| 简单 | 32 个 | 30~36 | 通常小于 10 ms |
| 中等 | 42 个 | 38~46 | 10~50 ms |
| 困难 | 50 个 | 46~54 | 50~200 ms |
因为唯一解验证失败后格子会被回填,所以最终盘面的空格数通常小于挖洞目标,这是正常现象。想把难度做得更细致,可以把挖洞逻辑封装成generatePuzzle(const int solution[9][9], int targetHoles),在循环里按幂次尝试不同挖法。
2.4 用行/列/宫位表加速合法性判断
上面isPlaceable每次循环扫描,量级很小;但挖洞验证唯一解时递归次数多,每次都重新扫描三组 24 个格子有点浪费。更常见的做法是维护三个位掩码数组:
bool rowUsed[9][10], colUsed[9][10], boxUsed[9][10]; bool fastCheck(int row, int col, int val) { int boxId = (row / 3) * 3 + (col / 3); return !rowUsed[row][val] && !colUsed[col][val] && !boxUsed[boxId][val]; }rowUsed[row][val]为 true 表示该行已用过这个数字。回溯填数时在递归前把三个位标记为 true,递归回退时还原为 false。换成位运算(rowMask[row] >> val) & 1可再快一点,但课程设计到bool表这一层已经足够清晰,方便答辩讲代码。
3. EasyX 渲染层:网格绘制、数字绘制与高亮刷新
3.1 窗口尺寸与坐标约定
EasyX 的画布默认以窗口左上角为原点,x 向右、y 向下。数独盘固定 9x9,把窗体设成 630×630 后每格正好 70 像素。窗口尺寸、单元格尺寸用宏定义,不要散落在代码各处,日后改 700 窗口或做窗口缩放只动两个常量。
#include <graphics.h> #define CELL_SIZE 70 #define GRID_SIZE 9 #define WIN_W (CELL_SIZE * GRID_SIZE) #define WIN_H (CELL_SIZE * GRID_SIZE) void initWindow() { initgraph(WIN_W, WIN_H); setbkcolor(WHITE); cleardevice(); }initgraph创建绘图窗口,setbkcolor设置背景色,cleardevice用背景色清屏。EasyX 在 VS 里用 MSVC 编译(MinGW 没有 graphics.h),所以环境上需要勾选安装“使用 C++ 的桌面开发”工作负载;Release 模式跑课程设计程序时,还依赖 Visual C++ Redistributable 运行库。
EasyX 渲染像素时,坐标 0..630 对应 0..9 格边界,绘制 3×3 粗线时第 0、3、6、9 条线正好落在格子边界上。如果窗口宽度不是 9 的整数倍,line 坐标会出现小数截断,网格线会偏 1 像素,所以 630 这类可整除的尺寸不会有这个问题。
3.2 网格绘制与粗细线分层
画网格要分两层:第 0、3、6、9 条线用粗线深色,其余用细线浅色。很多示例把 18 条线全都画成同样颜色,视觉上 3×3 宫不明显,玩家很难快速找到宫边界。颜色用一个isBold分支区分开即可。
void drawGrid() { for (int i = 0; i <= GRID_SIZE; i++) { bool bold = (i % 3 == 0); setlinecolor(bold ? RGB(30, 30, 30) : RGB(170, 170, 170)); setlinestyle(PS_SOLID, bold ? 3 : 1); int pos = i * CELL_SIZE; line(0, pos, WIN_W, pos); // 横线 line(pos, 0, pos, WIN_H); // 竖线 } }setlinecolor设置画笔颜色,setlinestyle(PS_SOLID, n)设置线宽为 n 像素。粗线和细线共用一个循环,切换线宽与颜色在循环内完成,不需要把横线竖线分开画。这里pos = i * CELL_SIZE计算每条线的像素位置,i 从 0 到 9,保证最后一根线落在 630 位置,不会有边界被裁掉。
3.3 单元格数字绘制与居中算法
数字绘制最容易被忽略的是文字居中。如果直接用格子左上角作为outtextxy的起点,数字会偏右下。先用textwidth(text)和textheight(text)算出文本的实际宽高,再用格子中心坐标减去半宽半高得到文本左上角。
void drawCellValue(int row, int col, int value) { int x0 = col * CELL_SIZE, y0 = row * CELL_SIZE; if (value == 0) return; settextstyle(28, 0, L"Segoe UI"); setbkmode(TRANSPARENT); settextcolor(RGB(20, 20, 20)); wchar_t buf[2] = { static_cast<wchar_t>(L'0' + value), L'\0' }; int tw = textwidth(buf), th = textheight(buf); int x = x0 + (CELL_SIZE - tw) / 2; int y = y0 + (CELL_SIZE - th) / 2; outtextxy(x, y, buf); }setbkmode(TRANSPARENT)防止文字自带白色背景块。textwidth和textheight必须在settextstyle之后调用,否则拿到的是上一轮字体大小的数据,居中的位置会偏。数字是单个宽字符,用wchar_t buf[2]装一位数字加一个结尾空字符,避免走中文字符串转换。
3.4 高亮、错误格和局部刷新策略
高亮和错误格本质是与数字渲染叠加的矩形。选中格填浅蓝,冲突格填浅红,普通格保持白色。为了避免矩形把网格线盖掉,填充矩形要内缩 2 像素,再用白色描边隐藏边框。
| 绘制对象 | 调用的 EasyX API | 选型原因 |
|---|---|---|
| 窗口背景 | setbkcolor + cleardevice | 一次性设置底色 |
| 网格线 | setlinecolor + setlinestyle + line | 粗细线分离 |
| 格子底色 | setfillcolor + fillrectangle | 内缩防覆盖网格 |
| 选中/错误格 | fillrectangle 叠加色块 | 覆盖后再重画数字 |
| 数字文本 | settextstyle + outtextxy | 宽字符居中 |
刷新方面,最简单的做法是每次交互后全量重画:清屏、画网格、画所有格子颜色、画数字。630×630 窗口的绘制量很小,但 Debug 模式下频繁cleardevice会闪屏。用BeginBatchDraw()把一帧内所有绘制命令缓存起来,交互结束再EndBatchDraw()统一输出,闪烁基本消失。
void renderFrame() { BeginBatchDraw(); cleardevice(); drawGrid(); for (int r = 0; r < 9; r++) for (int c = 0; c < 9; c++) { bool isSel = (r == selectedRow && c == selectedCol); bool isErr = mistakes[r][c] == 1; if (isSel || isErr) drawCellBg(r, c, isErr ? LIGHTRED : LIGHTBLUE); drawCellValue(r, c, board[r][c]); } EndBatchDraw(); }BeginBatchDraw和EndBatchDraw成对出现,中间的所有绘图命令进入离屏缓冲。EndBatchDraw放在 renderFrame 末尾,整个函数被一次交互调用时,屏幕只会更新一帧,不会出现画到一半的画面。这对课程设计足够用,不必上SetWorkingImage做离屏位图。
4. 交互层实现:鼠标选格、键盘填数与即时冲突判定
4.1 EasyX 消息循环与鼠标定位
EasyX 的getmessage会阻塞等待输入,只要玩家不按键,程序就卡在消息读取处,计时器和动画都无法刷新。用peekmessage非阻塞轮询更适合需要持续刷新的界面。事件从EX_MOUSE | EX_KEY两类里取,msg.x、msg.y除以格子尺寸即得行列号。
ExMessage msg; while (running) { while (peekmessage(&msg, EX_MOUSE | EX_KEY)) { if (msg.message == WM_LBUTTONDOWN) { int r = msg.y / CELL_SIZE; int c = msg.x / CELL_SIZE; if (r >= 0 && r < 9 && c >= 0 && c < 9) { selectedRow = r; selectedCol = c; } } else if (msg.message == WM_KEYDOWN && msg.vkcode >= '1' && msg.vkcode <= '9') { if (selectedRow != -1) trySetNumber(selectedRow, selectedCol, msg.vkcode - '0'); } } renderFrame(); Sleep(10); }selectedRow与selectedCol初始为 -1,表示没有任何选中格,防止按下数字键时访问数组越界。msg.vkcode的数字键值就是 ASCII 码,'9' - '1'之间的差值直接转成数字 1 到 9。Sleep(10)把主循环帧率压到约 100 FPS,CPU 占用不会拉满,答题演示时也不会显得卡。
4.2 题目格、玩家格、冲突格的三数组模型
只用int board[9][9]存盘面,无法区分哪些是题目固定值、哪些是玩家后来填的。把状态拆成三个二维数组:
int board[9][9]; // 当前盘面 int solution[9][9]; // 终盘答案,只读 bool isFixed[9][9]; // 题目给出的固定值 int mistakes[9][9]; // 冲突标记isFixed在挖洞时由挖洞函数写入,玩家填入的数字永远不会置位。solution保留生成终盘时的完整数组,作为提示和最终判定依据。mistakes只有 0/1 两个值,用int数组是因为 memset 清零和数组遍历都不需要位运算,比bool在 Debug 视图里更直观。
trySetNumber是交互层唯一入口,逻辑按照固定格不可改、空格填入、填入后重算冲突顺序执行:
void trySetNumber(int row, int col, int num) { if (isFixed[row][col]) return; if (num == board[row][col]) return; // 同一数字重复按,忽略 board[row][col] = num; updateMistakes(); }重复按同一数字时直接返回,避免触发无意义的冲突重算。只有数字真的变化,才更新盘面和冲突标记。这种做法比“每次按键都全盘检查”省去大量无效计算,也避免光标在同一个格子连续按 5 时不断刷新闪烁。
4.3 行、列、宫三重冲突判断与错误标记扩散
冲突检查要同时覆盖当前格所在行、所在列和所在 3×3 宫。每填一个数字,只检查该格与其他 24 个格子的关系是不够的——如果两个玩家格子同在一个宫里都填了 5,后一个格子会标红,前面的格子却保持白色,视觉上矛盾。所以每次更新后要做一次全盘重扫,把当前盘面所有冲突都重算一遍。
bool checkConflict(const int b[9][9], int r, int c) { int v = b[r][c]; if (v == 0) return false; for (int j = 0; j < 9; j++) if (j != c && b[r][j] == v) return true; for (int i = 0; i < 9; i++) if (i != r && b[i][c] == v) return true; int br = r / 3 * 3, bc = c / 3 * 3; for (int i = br; i < br + 3; i++) for (int j = bc; j < bc + 3; j++) if ((i != r || j != c) && b[i][j] == v) return true; return false; } void updateMistakes() { memset(mistakes, 0, sizeof(mistakes)); for (int r = 0; r < 9; r++) for (int c = 0; c < 9; c++) if (!isFixed[r][c]) mistakes[r][c] = checkConflict(board, r, c); }updateMistakes全盘扫描 81 格,每格 26 次比较,单帧开销不到 3000 次整数比较,对每秒 100 帧的循环无压力。刻意不保留上一次的mistakes状态,每次从零重建,能避免“上一个冲突格已经改了,但标记没清除”的残留问题。
4.4 胜利条件与重开流程
| 事件 | 判定条件 | 反馈动作 |
|---|---|---|
| 鼠标点击格子 | 坐标落在 9×9 内 | 更新选中格并高亮 |
| 键盘数字键 | 选中格存在且非固定格 | 填入数字并重算冲突 |
| 每次落子后 | 所有格子非空且无冲突 | 弹出胜利提示并停止计时 |
| 重新开始 | 确认胜利或主动放弃 | 重新生成盘面并清零计时 |
胜利判定的一个细节是:不能只检查 81 格非空,还要同时检查mistakes全为 0。只检查非空的话,如果玩家有冲突数字但恰好填满了整个盘面,程序也会误判胜利。两个条件缺一不可。
bool isWin() { for (int i = 0; i < 9; i++) for (int j = 0; j < 9; j++) if (board[i][j] == 0 || mistakes[i][j]) return false; return true; }胜利提示可以用MessageBox直接弹窗,也可以用 EasyX 在窗口中央画一个半透明面板并输出“完成”。弹窗最简单,验收时也不容易出问题;如果想做得精致一点,画一个 300×200 的白色矩形框住提示文本,效果比系统的 MessageBox 更像一个游戏界面。
5. 计时、提示、存档与验证调试技巧
5.1 计时和错误计数的统计口径
用time_t startTime在盘面生成后记录起点,渲染时time(nullptr) - startTime得到已用秒数,再转成 mm:ss 文本。错误计数的规则要明确:只有空格时填入一个冲突数字才累计一次,重复修改同一个错误格不重复计数,否则玩家来回改一个数字会把错误数刷到几百。
5.2 提示与撤销
提示功能给 3 次配额,每次把选中格的答案从solution直接写到board。撤销用std::vector<Step>记录历史,Step存坐标和旧值,撤销时弹出末尾记录并恢复。限制历史不超过 50 步,防止长时间游玩后内存无限增长。撤销后必须重新updateMistakes(),否则被撤销格子的红色标记不会清除。
5.3 二进制存档
读档和存档用二进制流最稳,不用解析文本。一次把六个数组连写进文件,读回来按同样顺序读取即可。
struct SaveData { int board[9][9], solution[9][9]; bool isFixed[9][9]; int elapsedSeconds, errorCount; }; bool saveGame(const char* path) { SaveData data; memcpy(data.board, board, sizeof(board)); memcpy(data.solution, solution, sizeof(solution)); memcpy(data.isFixed, isFixed, sizeof(isFixed)); data.elapsedSeconds = elapsedSeconds; data.errorCount = errorCount; FILE* fp = fopen(path, "wb"); if (!fp) return false; fwrite(&data, sizeof(data), 1, fp); fclose(fp); return true; }二进制存档对课程设计有两个好处:内存布局直接拍到文件,不需要考虑,分隔符或空格解析;读档时fread一次调用即可还原全部状态,不涉及字符串转整数。
5.4 三个最常被写的踩坑点
第一,outtextxy的字符串参数必须是宽字符,界面文本一律加L前缀,否则中文显示乱码。第二,Debug 模式闪屏比 Release 明显,用BeginBatchDraw/EndBatchDraw包住完整渲染函数,不要只在部分绘制代码外包一层。第三,唯一解验证死循环时,把limit参数先改成 1,确认递归能找到第一个解,再改回 2,能迅速区分“递归本身穷尽不下”和“第二次求解被提前退出逻辑卡住”两类问题。
调试坐标映射有一个很实用的手段:在trySetNumber入口插入一行printf("trySet(%d,%d)=%d\n", row, col, num);,运行后用鼠标依次点左上角、中心、右下角三个格子,对照控制台输出就能验证行号列号是否被 90 度调换。定位完再删掉这行日志。
本文还有配套的精品资源,点击获取