ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用EasyX写C++五子棋:课程设计到人机AI的完整实战

用EasyX写C++五子棋:课程设计到人机AI的完整实战 简介基于EasyX图形库的C五子棋游戏完整源码包面向C初学者、游戏开发爱好者以及计算机图形学课程实践者帮助了解从界面搭建、逻辑设计到游戏成型的完整开发思路。压缩包共36个文件总大小约22.92MB以3个C源文件与3个头文件作为核心逻辑另有14张JPG与3张PNG图片绘制棋盘、背景和按钮MP3/WAV/OGG音频带来背景音乐与落子音效并附有Visual Studio解决方案、Git配置文件和说明文档整体采用zip格式封装内部目录划分清晰便于按需检索和二次开发。项目实现基于二维数组的棋盘数据结构和胜负判定算法同时提供人机对战AI入口图形层通过EasyX完成渲染与事件响应适合课程设计、结课作业或业余练手。已有203人学习下载兼具图形界面与基础算法训练价值是上手C游戏开发的实用样例。1. 用 EasyX 写 C 五子棋为什么这个组合最适合课程设计和入门实战如果你正在找 C 课程设计题目或者刚学完语法想做个像样的图形界面项目基于 EasyX 库的 C 五子棋游戏几乎是绕不开的选择。它不依赖 Qt、MFC 那套重型框架一个头文件加一个链接库就能在 Visual Studio 或 Dev-C 里跑起来核心逻辑全是 C 语法本身——数组、循环、函数、文件读写正好把课本知识落成看得见摸得着的东西。很多人纠结用控制台还是带界面我的看法很直接五子棋这种棋盘固定在 15×15 或 19×19 的项目EasyX 的画线、画圆、鼠标消息处理刚好覆盖需求代码量控制在 600~900 行之间既不会因为太简单撑不起报告也不会因为太复杂在答辩前翻车。这个方案能解决的是用最少的图形学知识做出一个能玩、能判输赢、能存档的完整游戏适合 C 刚入门想进阶的学生也适合想快速验证算法思路的开发者。下文会从窗口搭建、棋子绘制、胜负判定、人机对战到存档读档完整过一遍每个环节都给可复现的代码和参数最后把最常见的坑挑出来讲。2. EasyX 下的五子棋框架搭建窗口、棋盘与首轮交互2.1 为什么用 EasyX 而不是 raylib 或 SDL选图形库之前先想清楚你要什么。raylib 和 SDL 是跨平台方案但配置起来要下载额外依赖、设置链接路径对只装了 Visual Studio 的 Windows 用户来说第一步就容易劝退。EasyX 是纯 Windows 平台的图形库本质是对 GDI 的封装安装就是下载一个安装包它会自动识别 VS 版本并配置好 include 和 lib 路径几分钟就能开始写。另一个实际考虑是学习成本。EasyX 的 API 风格跟 C 标准库接近画一个圆就是circle(x, y, r)画一条线就是line(x1, y1, x2, y2)不需要理解消息循环、渲染管线这些概念。SDL 那套要自己处理事件循环和纹理渲染对刚学完 C 语法的人来说跨度有点大。如果你以后想做跨平台游戏再转 raylib 也不迟但五子棋这个项目用 EasyX 能把精力全部放在游戏逻辑上而不是折腾环境。还有一点容易被忽略EasyX 对中文支持很友好outtextxy直接输出中文不会乱码这对课程设计要写菜单、显示提示信息很重要。用 SDL 默认字体处理中文反而要多做几步。2.2 初始化窗口尺寸、坐标系和双缓冲第一个可跑的代码是创建窗口并画棋盘。我的习惯是把棋盘格数定成 15×15每个格子边长 40 像素棋盘左边距和上边距各留 30 像素。这样算下来棋盘区域是 15×40600上下各 30 的边距再加底部留 80 像素放提示文字总窗口就是 660×740。#include graphics.h #include conio.h // 棋盘参数 const int BOARD_SIZE 15; // 15x15 棋盘 const int CELL_SIZE 40; // 每格边长像素 const int MARGIN 30; // 棋盘左边距/上边距 const int PANEL_HEIGHT 80; // 底部提示区高度 // 计算窗口尺寸 const int WIN_WIDTH MARGIN * 2 CELL_SIZE * (BOARD_SIZE - 1); const int WIN_HEIGHT MARGIN * 2 CELL_SIZE * (BOARD_SIZE - 1) PANEL_HEIGHT; void initGameWindow() { initgraph(WIN_WIDTH, WIN_HEIGHT); setbkcolor(RGB(218, 178, 115)); // 木纹黄底色 cleardevice(); } void drawChessboard() { // 画 15x15 的网格线 setlinestyle(PS_SOLID, 1); setlinecolor(BLACK); for (int i 0; i BOARD_SIZE; i) { int x MARGIN i * CELL_SIZE; int y MARGIN i * CELL_SIZE; line(MARGIN, y, MARGIN CELL_SIZE * (BOARD_SIZE - 1), y); // 横线 line(x, MARGIN, x, MARGIN CELL_SIZE * (BOARD_SIZE - 1)); // 竖线 } // 画星位天元和四个星 int starPos[5][2] {{7,7}, {3,3}, {3,11}, {11,3}, {11,11}}; for (int i 0; i 5; i) { int cx MARGIN starPos[i][0] * CELL_SIZE; int cy MARGIN starPos[i][1] * CELL_SIZE; fillcircle(cx, cy, 3); } }这段代码有两个关键点。一是坐标系EasyX 的原点在窗口左上角x 轴向右y 轴向下所以画横线时 y 固定、x 从 MARGIN 变到最大画竖线时 x 固定、y 变化。二是窗口尺寸计算15 个格点之间只有 14 个间隔所以宽度是2 * MARGIN CELL_SIZE * (BOARD_SIZE - 1)很多新手在这里多算一个格子导致棋盘最右线贴着窗口边缘。如果你拿到一段现成源码觉得棋盘位置不对先检查是不是这里写成了* BOARD_SIZE。实际运行时我建议在initgraph之前调用一句SetConsoleTitle(五子棋 - EasyX)方便任务管理器里识别进程Windows 下窗口标题在任务栏也会显示。这个不是必须的但如果你的课程设计报告需要截图说明有个明确的窗口标题会更规范。2.3 鼠标点击转棋盘坐标边界判断与防抖EasyX 的鼠标消息通过MOUSEMSG结构体获取核心是GetMouseMsg()和peekmessage()两种方式。GetMouseMsg会阻塞等待用户点击peekmessage是非阻塞的适合游戏循环里用。五子棋是回合制游戏用阻塞式等待反而简单——每次轮到当前玩家时等一次点击。#include graphics.h // 鼠标点击的位置窗口坐标转换为棋盘行列号 // 返回 true 表示点在合法棋盘范围内 bool getChessPos(int row, int col) { MOUSEMSG msg GetMouseMsg(); // 阻塞等待鼠标消息 if (msg.uMsg ! WM_LBUTTONDOWN) return false; // 窗口坐标转棋盘坐标 int x msg.x; int y msg.y; // 先做粗判断是否在棋盘正方形区域外 if (x MARGIN - 10 || x MARGIN (BOARD_SIZE - 1) * CELL_SIZE 10) return false; if (y MARGIN - 10 || y MARGIN (BOARD_SIZE - 1) * CELL_SIZE 10) return false; // 找最近交叉点 int nearestCol (int)((x - MARGIN CELL_SIZE / 2) / CELL_SIZE); int nearestRow (int)((y - MARGIN CELL_SIZE / 2) / CELL_SIZE); // 二次校验最近交叉点坐标与点击点距离不能超过半格 int dx abs(x - (MARGIN nearestCol * CELL_SIZE)); int dy abs(y - (MARGIN nearestRow * CELL_SIZE)); if (dx CELL_SIZE / 2 || dy CELL_SIZE / 2) return false; row nearestRow; col nearestCol; return true; }这里有个容易忽略的细节用户点在两格交叉点之间的空白区域时程序会因为四舍五入选中一个并不想下的位置。我的处理方式是先粗判是否落在棋盘范围内放宽 10 像素有的项目甚至放宽 20找到最近的交叉点之后再做一次距离校验距离超过半格就认为这次点击无效重新等待下一次点击。这种先粗后精的双重判断在触摸屏或鼠标精度不高的场景下尤其重要否则会频繁落错子。2.4 棋盘的落子状态存储二维数组与边界冗余棋盘状态的存储方式很多二维数组、一维数组加行列运算、位棋盘。五子棋规模小用二维数组最直接。我用int board[BOARD_SIZE][BOARD_SIZE]0 表示空1 表示黑子2 表示白子。这里有个设计决策数组下标直接用行列号不做 1 到 15 的偏移这样检查越界时统一用row 0 row 15的逻辑。落子后要立即重绘对应位置的棋子。这里我把落子和重绘绑在一起——落子函数里同时画出黑白圆不需要单独做刷新操作。如果你拿到的源码里有落子后调用FlushBatchDraw()这行这是 EasyX 的批量绘图机制后面避坑章节会专门讲。int board[BOARD_SIZE][BOARD_SIZE] {0}; void placePiece(int row, int col, int player) { board[row][col] player; // player: 1黑, 2白 int cx MARGIN col * CELL_SIZE; int cy MARGIN row * CELL_SIZE; setfillcolor(player 1 ? BLACK : WHITE); fillcircle(cx, cy, CELL_SIZE / 2 - 2); // 半径略小于半格留出棋格间隙 // 黑子加白边白子加黑边增强视觉区分 setlinecolor(player 1 ? WHITE : BLACK); circle(cx, cy, CELL_SIZE / 2 - 2); }CELL_SIZE / 2 - 2这个参数是我试了几个值后定下来的。半径比半格小 2 像素棋子之间会有一条细缝棋盘网格线还能透出来视觉上更像棋子放在交叉点上而不是糊成一片。如果你用CELL_SIZE / 2相邻棋子会紧挨着网格线被完全遮住棋盘显得臃肿。3. 五子棋的核心规则实现胜负判定算法与落子合法性3.1 胜利判定四方向扫描法及其边界条件五子棋的胜负判定是整个项目最核心的算法也是课程设计答辩时老师一定会问的部分。最常见的实现是每次落子后以刚落下的棋子为中心沿着水平、垂直、两条对角线共四个方向各扫描一遍累计同色连续棋子数任一方向达到 5 就判胜。// 判定 (row, col) 处的棋子是否胜出 // 四个方向水平(1,0)垂直(0,1)主对角线(1,1)副对角线(1,-1) bool checkWin(int row, int col, int player) { int directions[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int d 0; d 4; d) { int dx directions[d][0]; int dy directions[d][1]; int count 1; // 当前棋子自身先算一个 // 正方向延伸 for (int step 1; step 5; step) { int nr row dy * step; int nc col dx * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] player) count; else break; } // 反方向延伸 for (int step 1; step 5; step) { int nr row - dy * step; int nc col - dx * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] player) count; else break; } if (count 5) return true; } return false; }这个实现的优点是从落子点往两端展开不需要扫描整行整列时间复杂度是常数级每次落子最多检查 4 个方向 × 8 个位置。最容易写错的地方是方向向量的符号——主对角线方向是(1, 1)即行和列都加一副对角线方向是(1, -1)即行加一、列减一。如果你在这两个方向上搞混斜线胜利永远判不出来。我的建议是方向向量用先行后列的顺序{行增量, 列增量}并且在代码注释里写清楚不然过两周自己再看也会懵。这里还有个细节要说明判断条件是count 5而不是count 5。五子棋规定超过 5 子也算赢比如六连、七连所以用大于等于。但严格规则里长连在某些禁手规则下算输这里做课程设计不用那么复杂 5就够。3.2 落子合法性检查重复落子判断是第一个坑很多新手在落子函数里忘记检查目标位置是否已有棋子导致用户在同一交叉点反复点击能把棋子覆盖掉或者白子盖在黑子上。这个检查必须在更新board数组之前完成。bool isValidMove(int row, int col) { if (row 0 || row BOARD_SIZE) return false; if (col 0 || col BOARD_SIZE) return false; if (board[row][col] ! 0) return false; return true; }为什么这么简单的东西要单独写因为后面加人机对战时AI 的每一步走子也走同样的合法性检查。如果将来你想扩展成五子棋变体比如 19 路棋盘、六子棋只需要改BOARD_SIZE常量合法性检查不用动。另外在getChessPos里做过边界粗判但那是针对窗口坐标的这里是针对棋盘坐标的二次校验两者职能不同别为了省事合并。3.3 回合切换与平局处理游戏主循环的状态机游戏主循环用while(true)加标志位实现。我用一个currentPlayer变量表示当前轮到谁每次有效落子后切换。平局判断有两种做法简单版是落子数达到 22515×15时宣布平局更稳妥的是每次落子后遍历棋盘检查是否还有空位但遍历 225 个位置开销很小不在乎这点性能。int currentPlayer 1; // 1黑, 2白 bool gameOver false; int moveCount 0; // 游戏主循环 void gameLoop() { while (true) { if (!gameOver) { // 显示当前回合提示 settextstyle(20, 0, _T(宋体)); setfillcolor(RGB(218, 178, 115)); solidrectangle(0, WIN_HEIGHT - PANEL_HEIGHT, WIN_WIDTH, WIN_HEIGHT); settextcolor(currentPlayer 1 ? BLACK : RED); char tip[64]; sprintf_s(tip, 轮到 %s 方落子, currentPlayer 1 ? 黑 : 白); outtextxy(20, WIN_HEIGHT - PANEL_HEIGHT 15, _T(tip)); int row, col; if (!getChessPos(row, col)) continue; if (!isValidMove(row, col)) { // 非法落子提示并重新等待 continue; } placePiece(row, col, currentPlayer); moveCount; if (checkWin(row, col, currentPlayer)) { // 宣告胜利 settextstyle(30, 0, _T(黑体)); settextcolor(RED); char winMsg[64]; sprintf_s(winMsg, %s 方胜利, currentPlayer 1 ? 黑 : 白); outtextxy(WIN_WIDTH / 2 - 80, WIN_HEIGHT / 2 - 20, _T(winMsg)); gameOver true; } else if (moveCount BOARD_SIZE * BOARD_SIZE) { outtextxy(WIN_WIDTH / 2 - 60, WIN_HEIGHT / 2 - 20, _T(平局)); gameOver true; } else { currentPlayer (currentPlayer 1) ? 2 : 1; } } else { // 游戏结束后按 ESC 退出按 R 重新开始 if (_kbhit()) { char ch _getch(); if (ch 27) break; // ESC if (ch r || ch R) { resetGame(); // 清空棋盘数组重置状态 drawChessboard(); gameOver false; moveCount 0; currentPlayer 1; } } } Sleep(10); // 防止 CPU 空转到 100% } }这段里sprintf_s和_T()是 Windows 下的安全版本和宽字符适配宏如果你用 Dev-C 的 MinGW 编译器sprintf_s可能不可用要改成sprintf。这是个跨编译器兼容的细节后面避坑章节会展开。Sleep(10)在游戏结束后那段空循环里尤其重要没有它 CPU 占用率会飙升到单个核心满载用任务管理器一看全是你的程序在跑容易被老师当成 bug 问。solidrectangle画提示区背景的作用是覆盖掉之前写的回合文字否则文字会层层叠加屏幕上全是轮到 黑 方落子的影子。这个先擦除再重写的技巧在整个 EasyX 开发中会反复用到。3.4 重新开始与悔棋数据结构里预留后悔药课程设计答辩时老师爱问的功能除了重开一局就是悔棋。悔棋的本质是把上一次落子的board位置清零再重绘整个棋盘。实现上需要保存落子历史用一个vectorpairint,int记录每一步的坐标悔棋时就弹出最后一个位置并重绘。#include vector std::vectorstd::pairint, int moveHistory; void undoMove() { if (moveHistory.empty()) return; auto last moveHistory.back(); moveHistory.pop_back(); board[last.first][last.second] 0; // 清空棋子 // 切换回上一步的玩家注意要连续撤销两次才算真实悔棋 currentPlayer (currentPlayer 1) ? 2 : 1; redrawBoard(); // 重绘整个棋盘 } void redrawBoard() { cleardevice(); drawChessboard(); for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) { int cx MARGIN c * CELL_SIZE; int cy MARGIN r * CELL_SIZE; setfillcolor(board[r][c] 1 ? BLACK : WHITE); fillcircle(cx, cy, CELL_SIZE / 2 - 2); setlinecolor(board[r][c] 1 ? WHITE : BLACK); circle(cx, cy, CELL_SIZE / 2 - 2); } } } }悔棋里的回合切换有个容易做错的地方如果是双人对战悔棋一步要把回合切回上一步的玩家。如果允许连续悔棋那每次 undo 都要把currentPlayer切反一次。我的实现里每弹出一步就切一次所以连悔两步会回到正确的玩家。如果你只做一个按钮一步悔棋这个逻辑最简单。重绘整个棋盘的开销很小225 个格子最多重画 225 个圆在现代 CPU 上是微秒级不需要做局部更新优化。4. 让游戏更好玩双人对战、人机 AI 与胜负高亮4.1 双人模式合法流程跑通后先别急着做 AI五子棋源码最基本的交付版本是双人对战也就是黑白双方轮流向getChessPos等鼠标点击。很多同学一上来就想做 AI 对战但 AI 本身有难度而且如果双人模式的流程没跑顺AI 加进去只会让 bug 更难查。我的建议是先完完整整跑通双人模式能落子、能判胜、能重新开始、能悔棋。这一套跑通了再做 AI 只是加一个当前玩家是电脑时自动算一步棋的分支不会破坏已有的对战结构。在gameLoop里加 AI 分支的方式是每次轮到电脑时不调用getChessPos等待鼠标而是调用一个 AI 函数返回落子坐标。这个坐标同样要经过isValidMove校验因为 AI 算出的位置可能因为边界处理有 bug 落在棋盘外。if (currentPlayer AI_PLAYER) { // AI 计算落子位置 int aiRow, aiCol; calculateAIMove(aiRow, aiCol); // AI 函数 if (isValidMove(aiRow, aiCol)) { placePiece(aiRow, aiCol, currentPlayer); moveCount; if (checkWin(aiRow, aiCol, currentPlayer)) { /* 判胜 */ } else currentPlayer (currentPlayer 1) ? 2 : 1; } }4.2 一个能用的简单 AI基于打分表的贪心策略完整实现 Alpha-Beta 剪枝搜索对课程设计来说太重而且游戏树深度一大搜索时间不可控。我用的方案是经典的逐点打分法遍历每个空位分别计算如果黑子下在这里的得分和如果白子下在这里的得分取两者最大值作为该位置的总分选择总分最高的位置落子。这种策略在棋力上大致相当于入门的会堵但不算太傻做课程设计展示足够。打分表的逻辑是评估一个位置在四个方向上形成的棋型。每个方向看两端的连续同色棋子数量和空位情况映射成分数// 棋型打分表简版 // 活四、冲四、活三、眠三、活二、眠二 int evaluatePoint(int row, int col, int player) { int score 0; int directions[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int d 0; d 4; d) { // 统计该方向上的连续棋子数 int count 1; int block 0; // 被封堵的方向数 int empty 0; // 开放端的空位数 // 正方向 int nr row directions[d][1]; int nc col directions[d][0]; while (nr 0 nr BOARD_SIZE nc 0 nc BOARD_SIZE board[nr][nc] player) { count; nr directions[d][1]; nc directions[d][0]; } if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) block; else if (board[nr][nc] ! 0) block; else empty; // 反方向 nr row - directions[d][1]; nc col - directions[d][0]; while (nr 0 nr BOARD_SIZE nc 0 nc BOARD_SIZE board[nr][nc] player) { count; nr - directions[d][1]; nc - directions[d][0]; } if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) block; else if (board[nr][nc] ! 0) block; else empty; // 根据 count, block, empty 查表打分 if (count 5) score 100000; else if (count 4) { if (empty 0) score 0; // 被完全堵死 else if (empty 1) score 10000; // 冲四 else score 50000; // 活四 } else if (count 3) { if (empty 0) score 0; // 堵死 else if (empty 1) score 1000; // 眠三 else score 5000; // 活三 } else if (count 2) { if (empty 0) score 0; else if (empty 1) score 100; else score 500; // 活二 } else if (count 1) { score 10; } } return score; } void calculateAIMove(int bestRow, int bestCol) { int maxScore 0; bestRow BOARD_SIZE / 2; bestCol BOARD_SIZE / 2; // 第一手直接下天元 if (moveCount 0) return; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) continue; // 进攻分假设 AI 下这里 int attackScore evaluatePoint(r, c, AI_PLAYER); // 防守分假设对手下这里 int defendScore evaluatePoint(r, c, HUMAN_PLAYER); // 进攻权重略高于防守让 AI 有主动性 int totalScore attackScore * 1.1 defendScore; if (totalScore maxScore) { maxScore totalScore; bestRow r; bestCol c; } } } }这个 AI 的打分表是我调试过的近似权重性能上遍历 225 个点乘以 4 个方向每次落子大概计算 900 次评估函数肉眼无延迟。问题在于它只看一步不会考虑到对手下一步会形成必胜这种深层博弈所以棋力有限。如果你想增强可以把防守分的权重调高到 1.2 或 1.3让 AI 更倾向于堵人。这个参数是玄学不同棋盘阶段效果不一样建议保留成常量方便调试。4.3 胜负高亮把胜利的五颗棋子标出来做完判胜之后强烈建议加一个胜利棋子高亮功能——把连成五子的那五个位置用红色圆圈标出来。这个功能有三层好处一是玩家对战能清楚看到赢在哪条线二是截图放到报告里比文字说明有力三是能验证checkWin的判定是否真的找对了方向。实现方式是让checkWin在判定成功时把命中的连续棋子坐标记录下来。为了不把接口搞复杂我用一个全局数组winPath[5][2]保存坐标int winPath[5][2]; // 保存连成五子的坐标 int winPathLen 0; bool checkWin(int row, int col, int player) { int directions[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int d 0; d 4; d) { int dx directions[d][0]; int dy directions[d][1]; // 收集正方向 反方向的连续同色棋子 winPathLen 0; winPath[winPathLen][0] col; winPath[winPathLen][1] row; winPathLen; for (int step 1; step 5; step) { int nr row dy * step; int nc col dx * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] player) { winPath[winPathLen][0] nc; winPath[winPathLen][1] nr; winPathLen; } else break; } for (int step 1; step 5; step) { int nr row - dy * step; int nc col - dx * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] player) { winPath[winPathLen][0] nc; winPath[winPathLen][1] nr; winPathLen; } else break; } if (winPathLen 5) { // 高亮这五个或更多子 for (int i 0; i winPathLen; i) { int cx MARGIN winPath[i][0] * CELL_SIZE; int cy MARGIN winPath[i][1] * CELL_SIZE; setlinecolor(RED); setlinestyle(PS_SOLID, 3); // 粗线圆圈 circle(cx, cy, CELL_SIZE / 2 - 2); } return true; } } return false; }这里面setlinestyle(PS_SOLID, 3)很重要——用默认的 1 像素线画红色圆圈在高分辨率屏上看不清加粗到 3 像素就非常醒目。如果你在这个过程中发现胜利判定是对的但高亮的棋子位置不对多半是把row和col的存入顺序弄反了数组存的是{列, 行}而不是{行, 列}画图时用坐标又是xcol, yrow两者映射错位就会出现高亮在别的交叉点上的诡异现象。4.4 先跑通再优化的一句话法则人机对战的完整框架跑通之后你手里就有了四个可以继续迭代的方向增强 AI 搜索深度、加入禁手规则、加入网络对战、加入开局库。我个人经验是每次只动一个方向并且保留旧版本因为 AI 搜索深度从 1 层加到 2 层时评判函数的行为会大变原来能赢的局面可能会平甚至输没有旧版本对照会非常难排查。5. EasyX 五子棋的常见坑与排查从编译到运行的避坑指南5.1 编译报错sprintf_s在 Dev-C 里不可用现象在 Visual Studio 里编译源码一切正常换到 Dev-C 打开同一份代码报错sprintf_s was not declared in this scope。原因sprintf_s是微软对 C 标准库sprintf做的安全扩展MinGW 编译器默认不完全支持。而很多课程设计源码是 VS 下写的网上流传的版本大量使用sprintf_s、scanf_s这类带_s后缀的函数换编译器就翻车。解决项目统一使用标准 C 的snprintf或std::string拼接。在我的代码里把sprintf_s(tip, ..., ...)改成snprintf(tip, sizeof(tip), ..., ...)后者在 VS 和 MinGW 下都能编译。如果你拿到的源码里有大量_s后缀函数可以用#define sprintf_s snprintf做个全局替换但注意scanf_s的参数个数跟scanf不同不能直接宏替换。5.2 运行黑屏或者闪退initgraph失败与缺少 easyx.h现象双击 exe 没反应或命令行窗口一闪而过控制台提示fatal error: easyx.h: No such file or directory。原因EasyX 安装包只配置了当前检测到的 Visual Studio 版本。如果你后来安装了新版本 VS、或者用了绿色版编译器include 路径不会自动更新。另外某些精简版系统缺少 GDI 相关组件会导致initgraph返回失败但这种情况少见。解决打开 EasyX 安装目录默认在C:\Program Files (x86)\EasyX里面有include和lib两个文件夹。在 VS 里打开项目属性 → VC 目录 → 包含目录手动添加 include 路径库目录添加 lib 路径。如果你用的是 Dev-C需要在工具 → 编译器选项 → 目录里同样配置并且确认编译器是 32 位——EasyX 官方不提供 64 位版本这是所有坑里最隐蔽的一个。5.3 窗口一直在闪烁棋子拖动时留下残影现象落子或擦除文字时窗口闪烁严重棋子区域出现拖影。原因默认的绘图模式是直接写显存每次circle、outtextxy都立即生效大量连续绘制操作就会产生闪烁。Z 序上旧的图形和新的图形交替产生视觉撕裂尤其在低帧率屏幕上非常明显。解决使用 EasyX 的批量绘图接口。在initGameWindow里调用BeginBatchDraw()在游戏循环每次处理完一轮交互后调用FlushBatchDraw()程序退出前调用EndBatchDraw()。这样所有绘制操作先在内存缓冲完成一次刷新到屏幕。注意FlushBatchDraw必须在绘制之后立即调用否则画面永远不更新看起来像死机。还有一个偷懒的方式是用cleardevice()全屏重绘但每帧清屏再画闪烁会加剧不是好选择。5.4 调试时鼠标点击无响应现象程序运行起来窗口正常但点棋盘任何位置都没反应控制台显示一直阻塞在等待输入。原因GetMouseMsg()是阻塞函数如果程序在等待时窗口被遮挡、最小化或者你的循环逻辑里提前调用了_getch()抢占了输入焦点鼠标消息可能被系统丢弃。另一个常见原因是窗口失焦——点击其他窗口再点回来消息队列里的点击事件不会自动补发。解决把鼠标获取改成非阻塞轮询方式用peekmessage(msg, EM_MOUSE)循环检查没有消息就Sleep(50)后再查。这个方法对调试尤其友好因为你可以随时在循环里加printf输出坐标而不影响消息接收。如果你的代码必须用GetMouseMsg至少确保窗口标题栏没有被其他程序覆盖并且鼠标点击的瞬间窗口是激活状态。5.5 中文乱码控制台正常但 EasyX 窗口文字全乱现象棋盘画出来了但提示文字、菜单中文全是乱码英文正常。原因EasyX 的outtextxy默认用 GBK 编码而你的源文件保存成 UTF-8 加上 BOM 后VS 编译出来的字符串字面量可能变成 UTF-8 字节序列。Windows 下中文字符串进 GDI 绘制函数时被当成 GBK 解码自然乱码。解决两种方案任选。一是在源文件顶部加#pragma execution_character_set(utf-8)这会让编译器把所有窄字符串字面量转成 GBK 编码二是所有中文都改用_T(中文)包裹配合settextstyle指定中文字体这样在 VS 下默认 ANSI 编码最稳定。如果你项目里同时存在 UTF-8 和 ANSI 的中文优先统一源文件编码格式VS 的文件 → 高级保存选项可以批量转码。5.6 文件读档存档课程设计加分项里的坑如果源码里包含存档读档功能最常见的 bug 是把二维数组直接fwrite写进文件但读档后棋盘坐标错位。原因通常是结构体对齐或字节序问题。我的方案是逐行写入每写一行加一个分隔符读档时逐行恢复。这里fopen要用二进制模式wb和rb否则 Windows 下会把你写入的\n自动转换成\r\n导致字节数不对。void saveGame(const char* filename) { FILE* fp fopen(filename, wb); if (!fp) return; fwrite(board, sizeof(int), BOARD_SIZE * BOARD_SIZE, fp); fwrite(currentPlayer, sizeof(int), 1, fp); fwrite(moveCount, sizeof(int), 1, fp); fclose(fp); } void loadGame(const char* filename) { FILE* fp fopen(filename, rb); if (!fp) return; fread(board, sizeof(int), BOARD_SIZE * BOARD_SIZE, fp); fread(currentPlayer, sizeof(int), 1, fp); fread(moveCount, sizeof(int), 1, fp); fclose(fp); redrawBoard(); gameOver false; }这里隐含一个问题读档后gameOver必须重置为 false否则你会看到棋盘恢复但点任何位置都没反应因为主循环卡在if (!gameOver)之外。我实际开发中就在这翻了一次车排查了半天发现是moveHistory没有同步更新导致悔棋功能在读档后失效。如果你要支持读档后的悔棋moveHistory也要保存或重建不能只存棋盘数组。6. 进阶调优把 AI 反应速度、视觉细节与代码结构再压榨一轮6.1 AI 延迟控制的实用技巧打分 AI 在当前 15×15 棋盘上耗时在毫秒级不需要刻意优化。但如果你扩展了棋盘到 19×19或者加了搜索深度AI 计算时间会指数增长。一个非常实用的招数是预计算空位表每次落子后把board中为 0 的位置收集到数组AI 只遍历这个数组而不是全部 361 个格点。开局阶段空位多省不了多少中盘阶段空位剩 100 多个效率能提升 30% 以上。另一个技巧是限制评估范围只评估距离已有棋子 2 格以内的空位。五子棋的落子几乎不可能跳出全局棋型之外这个剪枝策略不会明显影响棋力但计算量能减半。实现时用一个isNearPiece(r, c)函数检查周围 5×5 范围内是否有已落子没有就跳过。6.2 视觉打磨棋子渐变、坐标提示与音效占位课程设计答辩时功能一样的情况下视觉细节往往决定印象分。EasyX 支持setfillstyle设置渐变填充但用起来复杂我常用的偷懒方案是画两层圆下层是深色大圆上层是浅色小圆视觉上形成立体棋子效果。黑子的高光往左上偏移两三个像素看起来更像真实棋子被光照到。void drawPieceWithHighlight(int cx, int cy, int player) { // 底层——主体颜色 setfillcolor(player 1 ? RGB(30, 30, 30) : RGB(230, 230, 230)); fillcircle(cx, cy, CELL_SIZE / 2 - 2); // 上层——高光 setfillcolor(player 1 ? RGB(90, 90, 90) : RGB(255, 255, 255)); fillcircle(cx - 2, cy - 2, CELL_SIZE / 3); }这个双层圆的写法比直接fillcircle多了 5 行代码但效果立竿见影。配合窗口背景的棋盘木纹色RGB 218,178,115整个界面不需要额外贴图就显得完整。坐标提示方面setmousequeuestatus或MouseHit可以拿到鼠标实时位置在窗口角落显示当前坐标(7,9)这个小功能能直接展示你对 EasyX 鼠标系统的熟练度。6.3 代码结构的一个建议把游戏逻辑和绘图分离最后给个我自己写课程设计的习惯board数组和checkWin、isValidMove这些纯逻辑代码和drawChessboard、placePiece这些绘图代码分文件放。你可以在项目里建game_logic.h和graphics_ui.h两个头文件逻辑文件不依赖graphics.h方便你在没有图形环境的情况下单独测试 AI 算法——写单元测试或者用命令行模拟棋局都会更简单。这个分法在答辩时也很好讲先说游戏逻辑部分用了什么数据结构什么算法再演示 UI 层怎么调用逻辑层。老师听完会觉得你的分层意识好而不是把所有代码堆在一个 main.cpp 里。我见过太多五子棋源码是一个文件从头写到尾答辩时被问如果要把棋盘从 15 路改成 19 路需要改哪几个地方支支吾吾说不清楚。分层之后这个问题的答案很明确改BOARD_SIZE常量和drawChessboard里的星位表其余函数全部自动适配。写到这里五子棋从零到完整交付的路径已经很清楚。我最后悔的是当年第一版把胜负判定写成了只扫一整行一整列斜线胜利永远判不出来复盘了两天才发现方向向量的符号搞反。希望这篇文章能帮你绕过这个弯路也期待你做出比我这版更好的 AI 或交互设计让课程设计不再只是交差。希望帮到你。本文还有配套的精品资源点击获取
返回列表