C++五子棋人机对战完整实现:棋盘、AI评分与搜索

发布时间:2026/10/17 4:50:58
C++五子棋人机对战完整实现:棋盘、AI评分与搜索
简介一款以C编写的五子棋对战程序内含可执行文件与完整源码支持人机对战和人人对战适合C初学者、游戏开发爱好者、课程设计参考者及博弈算法学习人群。压缩包为rar格式整体仅106KB共收录7个文件三个可直接运行的exe文件分别对应鼠标版、键盘版、人对人版三个cpp源文件与之配套另有一个说明txt便于直接体验并对照代码学习目前已有180人学习或下载。源码在类设计上使用棋盘类与棋子类来抽象游戏状态人机模式中的AI落子逻辑可重点研读里面涉及基于规则判断或搜索类算法如Minimax、Alpha-Beta剪枝的决策思路人人模式则体现玩家输入合法性验证与棋盘更新的完整流程。鼠标版与键盘版源码分别演示了两种交互控制方式可从中学习输入捕获、事件响应和界面绘制的基本手法。研读这份紧凑的小型项目既能巩固C面向对象与STL容器运用的能力也能清晰理解游戏开发中逻辑层、交互层与界面层分离的设计思路适合在此基础上继续优化AI强度、增加功能或改进界面效果。1. 需求与目标一套可跑通完整对局的双模式五子棋某导师把课程设计题目发下来名单上写着“五子棋游戏人机或人人”。A同学一开始以为这是个递水的题目真打开编辑器才发现对战流程、落子判定、胜负判定、悔棋支持一起要做加上人机模式后还得处理AI选点一整套下来代码量不比管理系统少。这套C五子棋方案专门解决这个场景控制台界面无第三方图形库依赖用标准库就能编译运行玩法上同时支持“人人对战”和“人机对战”数据结构用二维数组AI层从棋型评分做到启发式选点。适合需要交课程设计、毕设或者想快速拿一份能跑的C游戏源码做参考的开发者。拿到手能跑、能改、能讲清楚。2. 总体架构设计棋盘数据结构、双模式主循环与回溯栈任何一个五子棋程序第一步不是写AI而是先把“棋盘”和“棋局”这两个基础概念定清楚。数据结构定了后面每个模块的代码都不会跑偏。这一章把最关键的棋盘建模、主流程、胜负判定和悔棋机制一次性交代清楚下一章才接手AI部分。2.1 棋盘建模二维数组、颜色约定与坐标范围五子棋标准盘是15×15所以棋盘用二维数组board[15][15]表示初始值0代表空位1代表黑棋2代表白棋。坐标统一使用0~14内部下标玩家输入的是1~15在入口处做一次减一映射。这样设计的好处是棋盘本身不需要额外的边界哨兵所有落子、扫描操作都用统一的inBoard函数判断越界代码不容易写出数组越界问题。const int N 15; // 棋盘边长 int board[N][N]; // 0空 1黑 2白 void initBoard() { memset(board, 0, sizeof(board)); } bool inBoard(int x, int y) { return x 0 x N y 0 y N; }为什么不开16×16用第0行第0列做哨兵虽然哨兵写法能减少一部分边界判断但会引入一个隐含约定有效下标从1开始所有循环都要从1遍历到N稍不注意就把边界点漏掉。统一用0~14加独立判断对新手更友好评审老师看代码时也不需要反复确认下标含义。2.2 双模式主循环用状态变量切换“人人/人机/退出”主程序用菜单进入两种模式内部对局循环完全复用同一套落子、判胜、悔棋逻辑区别只在“当前这步由谁来走”。PVP模式里黑方白方都是玩家输入PVE模式里玩家固定执黑AI执白玩家落子后立即进入AI计算。这样设计避免了把AI逻辑散落到主循环各个分支里后续做图形界面移植时主流程不用动。enum GameMode { MODE_MENU, MODE_PVP, MODE_PVE }; int main() { int mode; cout 1. 人人对战 2. 人机对战 0. 退出\n; cin mode; if (mode MODE_PVP) playPVP(); else if (mode MODE_PVE) playPVE(); else return 0; return 0; }对局循环的核心结构是“打印棋盘 → 读取输入 → 落子 → 判胜 → 换手”。换手只用一行turn 3 - turn因为1和2是两个玩家编号3减当前编号正好得到对方。这个写法在两人游戏里非常常用比if判断省心也不会出现两个玩家编号写反的低级错误。2.3 胜负判定从最后一手向四个方向延伸胜负只由最后一次落子决定所以判定起点放在落子点而不是全盘扫描。每次落子后以该点为原点向水平、垂直、主对角线、副对角线四个方向各统计连续同色棋子数量任何方向总数达到5即可判定获胜。代码里用方向向量数组统一处理避免写四段几乎一样的循环。int dirs[4][2] { {1, 0}, // 水平向右 {0, 1}, // 垂直向下 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; bool checkWin(int x, int y) { int color board[x][y]; if (color 0) return false; for (int d 0; d 4; d) { int cnt 1; // 当前落子点本身 for (int s 1; ; s) { int nx x dirs[d][0] * s; int ny y dirs[d][1] * s; if (!inBoard(nx, ny) || board[nx][ny] ! color) break; cnt; } for (int s 1; ; s) { int nx x - dirs[d][0] * s; int ny y - dirs[d][1] * s; if (!inBoard(nx, ny) || board[nx][ny] ! color) break; cnt; } if (cnt 5) return true; } return false; }这个写法天然覆盖长连情况连成六子或者更多也会判赢普通五子棋规则下没有问题。每次落子只需要几十次棋盘访问和整个棋盘大小无关。如果后续要加“有禁手”规则再单独做禁手检测即可胜负判定本身不需要推倒重写。2.4 悔棋撤销栈记录与清空恢复悔棋不能用“重放整盘棋”的方式实现因为重放会把回合状态、AI决策过程全部打乱。正确做法是维护一个坐标栈每落一子就入栈悔棋时从栈顶取出坐标并置0。人人对战一次悔棋要撤销两步黑一步白一步人机对战同理撤销玩家和AI各一步。struct Point { int x, y; }; vectorPoint steps; // 对局落子记录 void undoMove() { if (steps.empty()) { cout 没有可悔棋的步骤\n; return; } Point p steps.back(); steps.pop_back(); board[p.x][p.y] 0; }需要注意悔棋操作要放在“换手”之前还是之后我用统一约定悔棋命令处理后不切换当前回合玩家继续在当前回合下棋。这样玩家悔完棋不需要重新思考“现在轮到谁”体验更自然。栈记录还有一个额外作用对局结束后可以输出整盘棋谱答辩演示复盘时很加分。3. 人机AI实现棋型评分表、候选点收缩与两步搜索AI部分是整个项目里最像“玄学”的地方但课程设计阶段的AI核心其实很朴素把五子棋里常见的棋型转成数字让程序比较哪个点更好。关键在于棋型定义和候选点收缩。把这两点做扎实AI就能明显比随机落子强也足够在答辩时讲清楚。3.1 棋型识别给五子棋的典型棋型定分评分前先把棋型分成几个基本档位。我在课设里用的是一张简表按“连续子数”和“两端是否开放”两个维度定义。五连给压倒性高分活四次之冲四、活三依次降低。分值的差距要足够大AI才不会出现“对手下一步就能赢它还在补自己的三”这种翻车操作。棋型定义分值五连连续5子无论两端状态100000活四连续4子两端均未封堵50000冲四连续4子仅一端未封堵8000活三连续3子两端均未封堵4000眠三连续3子仅一端未封堵800活二连续2子两端均未封堵300眠二连续2子仅一端未封堵80这组数值不是拍脑袋定的。五连和活四的分数应当比其他棋型高出一个数量级AI才能优先处理“马上要赢”和“必须马上堵”的局面。分值表可以直接放进头文件里后续想调整AI风格改这几个常量就行不用动算法结构。3.2 落点评分进攻分与防守分的合并对于一个候选空位AI不仅要算“我下这里能形成什么棋型”还要算“我不下这里对手下这里会怎么威胁我”。所以评分函数会分别计算该点对AI颜色的进攻评分以及对对手颜色的防守评分最后按一定权重合并。常见的做法是进攻权重略高一点让AI有自己的进攻意图而不是全场追着对手跑。int evaluatePoint(int x, int y, int aiColor) { if (board[x][y] ! 0) return -1; int attack 0, defend 0; for (int d 0; d 4; d) { attack lineScore(x, y, aiColor, dirs[d]); defend lineScore(x, y, otherColor(aiColor), dirs[d]); } return attack * 10 defend * 9; }lineScore的具体实现是以(x,y)为中心点沿一个方向向量分别向正、反两个方向延伸统计连续同色棋子数以及端点的开放状态。这里最容易踩的坑是中心点重复计数所以cnt要从1开始延伸时不包含中心点本身。另一个常见问题是两个方向各扫各的没有把整条线上的棋型拼成完整判断导致活三被算成两个眠三。3.3 候选点收缩命中场上的存量棋子全盘225个点都做一次评分AI每步要做225次完整线扫描虽然不至于卡死但也没有必要。有效落子一定在已有棋子附近所以只扫描“距离任何已落棋子不超过2格”的空位候选点数量通常能压到30个以内AI决策瞬间完成。vectorPoint getCandidates() { vectorPoint pts; bool mark[N][N] {false}; for (int i 0; i N; i) { for (int j 0; j N; j) { if (board[i][j] 0) continue; for (int dx -2; dx 2; dx) { for (int dy -2; dy 2; dy) { int nx i dx, ny j dy; if (inBoard(nx, ny) board[nx][ny] 0 !mark[nx][ny]) { mark[nx][ny] true; pts.push_back({nx, ny}); } } } } } if (pts.empty()) { pts.push_back({N / 2, N / 2}); } return pts; }细节在mark数组。如果不做去重同一个空位会被周围多个已落子反复加入候选列表评分时重复计算既拖慢速度也会让AI在几个等价点之间摇摆。兜底中心点的逻辑只会在空棋盘开局时触发让AI第一手落在天元附近这是五子棋的第一手惯例。3.4 两步搜索把对手的反击也放进评估只做一层贪心AI容易被对手的三四成型打穿。更划算的增强是两层搜索先为每个候选点算评分取前8个作为迭代对象对每个候选点模拟AI落子后再模拟对手在剩余候选点中的最佳得分用两者差值作为该落点的最终分。这样能体现出“防守对手活三”的决策比单纯堆评分表有说服力得多。int twoStepScore(Point candidate, int aiColor) { int old board[candidate.x][candidate.y]; board[candidate.x][candidate.y] aiColor; int bestEnemy 0; for (Point p : getCandidates()) { if (p.x candidate.x p.y candidate.y) continue; bestEnemy max(bestEnemy, evaluatePoint(p.x, p.y, otherColor(aiColor))); } board[candidate.x][candidate.y] old; return evaluatePoint(candidate.x, candidate.y, aiColor) - bestEnemy; }注意这里的棋盘恢复old变量在函数开头保存return前必须写回board。如果忘了恢复AI后续看到的棋盘是脏的评分全部错乱这是两步搜索最容易翻车的位置没有之一。提示两步搜索不需要完整模拟对手落子只计算对手落点的评分。因为两步搜索的目标不是推演整盘棋而是评估“我走这里之后对手最凶的反击有多凶”。真要做完整博弈树那是极大极小搜索加α-β剪枝的范畴课设阶段用两层已经够讲清楚。4. 界面与交互实现控制台棋盘、输入解析与命令处理界面不是项目的核心但接口不能乱。控制台程序如果输入校验没做好演示时很容易当场崩掉。这一章讲棋盘绘制、输入校验和命令处理都是可以直接套用的函数级代码。4.1 棋盘绘制打印坐标轴与交叉点控制台棋盘的美观度优先级很低但可读性很重要。每一行、每一列都要有坐标落子棋子用X和O区分空格用单个点占位。这样在格子比较密的时候玩家仍然能快速定位自己该下哪里。void printBoard() { cout ; for (int col 1; col N; col) cout setw(2) col ; cout \n; for (int row 1; row N; row) { cout setw(2) row ; for (int col 1; col N; col) { int v board[row - 1][col - 1]; if (v 0) cout . ; else if (v 1) cout X ; else cout O ; } cout \n; } }为什么不直接在控制台输出●和○很多课程设计环境还是老版本Windows控制台Unicode符号可能变成乱码。用X和O最保险如果评委老师要求美观再替换成带颜色的图形界面也不迟。打印棋盘用了setw(2)需要包含头文件 这个细节很容易被遗漏编译报错时最先查它。4.2 输入解析与坐标校验非数字字符必须挡住玩家输入坐标时可能输入字母、小数、负数甚至直接敲回车。输入解析要做两层防线第一层用cin读取失败判断拦截非数字内容第二层校验坐标范围和该位置是否为空。两层缺一不可否则后面落子函数里到处都是防御性判断代码会越来越乱。bool readPoint(int x, int y) { cout 请输入落子坐标如 7 70 0 悔棋-1 -1 认输; if (!(cin x y)) { cin.clear(); cin.ignore(1024, \n); return false; } if (x 0 y 0) return false; // 命令在外部处理 if (x -1 y -1) return false; if (x 1 || x N || y 1 || y N) { cout 坐标超出范围请重新输入。\n; return false; } if (board[x - 1][y - 1] ! 0) { cout 该位置已有棋子请重新输入。\n; return false; } return true; }这里的cin.clear()作用是清除输入流的错误状态cin.ignore()把这一行残留的字符全部丢掉。为什么必须做这一步因为cin一旦读入非数字字符会进入fail状态后续所有读取操作都会直接失败程序表现就是“输入什么都没反应”演示时极其社死。4.3 人人对局回合控制轮流落子与胜负跳出人人模式的主函数是整个项目里最直白的一段逻辑初始化棋盘进入循环每回合打印棋盘、读取输入、落子、判胜、换手。命令处理放在readPoint返回false之后单独判断不要塞进readPoint内部否则“悔棋”和“输入非法”会混在一起提示语也说不清楚。void playPVP() { initBoard(); steps.clear(); int turn 1; int x, y; while (true) { printBoard(); cout (turn 1 ? 黑方 : 白方) 请落子\n; if (!readPoint(x, y)) { continue; // 命令处理可在此追加 } board[x - 1][y - 1] turn; steps.push_back({x - 1, y - 1}); if (checkWin(x - 1, y - 1)) { printBoard(); cout (turn 1 ? 黑方 : 白方) 获胜\n; break; } turn 3 - turn; } }这里的落子顺序有讲究先把PVE模式下AI的落子逻辑放进来会破坏“玩家输入 → 落子 → 判胜 → 换手”这个干净节奏。我一般会先把人人模式跑通再加AI全程只在“换手”前插入一步AI计算避免两个模式逻辑互相污染。4.4 人机对局回合控制玩家走完立刻接AI棋步人机模式主体与人人模式几乎相同唯一区别是玩家落子且没有获胜后马上进入AI计算并落子然后再次检查胜负。AI落子不需要输入校验但需要入栈悔棋时才能把AI的棋和玩家上一手一起撤销。void playPVE() { initBoard(); steps.clear(); int turn 1; int x, y; while (true) { printBoard(); cout (turn 1 ? 玩家 : AI) 请落子\n; if (turn 1) { if (!readPoint(x, y)) continue; board[x - 1][y - 1] turn; steps.push_back({x - 1, y - 1}); } else { Point aiMove aiDecide(2); board[aiMove.x][aiMove.y] 2; steps.push_back({aiMove.x, aiMove.y}); cout AI 落子 aiMove.x 1 aiMove.y 1 \n; } if (checkWin(x - 1, y - 1)) { printBoard(); cout (turn 1 ? 玩家 : AI) 获胜\n; break; } turn 3 - turn; } }AI决策函数aiDecide内部就是第3章的评分流程先取候选点再对每个候选点做两步搜索返回分数最高的点。注意AI落子后输出的坐标要加1因为内部下标从0开始玩家习惯的是1~15坐标。4.5 悔棋与认输命令命令链路的最后闭环命令约定从readPoint里透传出来0 0表示悔棋-1 -1表示认输。命令处理放在主循环的readPoint失败分支里用返回值或者一个命令枚举区分。悔棋时先判断当前栈里有没有足够棋子人人模式撤销两步人机模式撤销玩家和AI各一步不要只撤销一个否则后续回合状态完全错乱。void handleCommand(int cmd, int turn, bool over) { if (cmd COMMAND_UNDO) { undoMove(); undoMove(); // 人人模式撤销两步 cout 已悔棋请重新落子\n; } else if (cmd COMMAND_RESIGN) { cout (turn 1 ? 黑方认输白方胜利 : 白方认输黑方胜利) \n; over true; } }认输的回合判断同样依赖turn变量不需要额外记录谁先认输。整体交互流程其实只需要“读取输入 → 解析命令 → 执行 → 判胜 → 换手”五步别把菜单、下棋、AI三个逻辑全部揉进一个函数里后面改代码会轻松很多。5. 常见问题与避坑五子棋源码里最容易翻车的六个点这套源码的坑我见过不少人踩问题高度集中在坐标、边界、状态恢复这三类。下面按“现象 → 原因 → 解决”列出每一条都来自实际跑动结果不是纸上谈兵。5.1 棋盘上出现“双子”相同位置叠了两个颜色现象对局中途某个坐标显示出两个不同颜色的棋子或者后落的棋子直接覆盖了之前的棋子。原因落子前没有检查board[x][y]是否为空坐标输入后直接赋值。可能是readPoint里漏了校验也可能是move函数落子时没有二次防御。解决在readPoint里增加位置占用检查同时把落子封装成move函数move内部再判断一次。两道防线写进代码后这个现象基本不会复现。5.2 AI选点总往边角跑或者空棋盘开局找不到点现象空棋盘进入人机模式后AI第一手不落天元反而落到边界点或者中盘时AI突然返回一个明显不合理的点。原因getCandidates在空棋盘时收集不到候选点如果没有兜底返回AI会对一个无效坐标做赋值越界或数据错乱边界点也容易被评分表误判为高分。解决getCandidates末尾加一行兜底候选点为空时返回{N / 2, N / 2}。同时给边界点做一个轻微减分让AI更倾向开阔区域开局落子更有章法。5.3 斜线成五子不判胜竖线判了横线没判现象横向、纵向能正常判胜斜着连成五子却一直不触发胜利或者只写了横竖两个方向副对角线漏掉。原因dirs数组只写了{{1,0},{0,1}}少了斜向方向向量。检查方向不完整是胜负判定最高频的问题。解决四方向向量写全水平、垂直、主对角线、副对角线。Self-check方式找一手中间连成五子的棋局逐方向跑一遍四个方向都要弹出胜利提示。这条适合任何五子棋项目不只是本方案。5.4 输入字母后程序像死机任何按键都没反应现象玩家不小心输入了字母程序不再响应任何输入看起来像进入了死循环。原因cin int 提取失败后输入流进入fail状态。后续所有读取操作都会立即失败而不是等待用户输入程序就一直空转。解决在readPoint开头判断if (!(cin x y))进入分支后先cin.clear()清除错误位再cin.ignore(1024, \n)把这一行残留字符全部吞掉然后重新提示。这段防御代码在任何控制台小游戏里都适用。5.5 两步搜索后AI越走越迷评分变成负数现象加了AI搜索层之后AI放着明显的进攻三不去走反而选一个分数很低的点甚至整盘棋都像随机落子。原因模拟落子后没有恢复棋盘状态old值没有写回board或者防守分被错误地当成正分叠加导致AI认为“对手越有机会赢的位置我越要主动送上去”。解决在twoStepScore里用old保存原状态return前必须恢复。防守分按威胁性计算可以做一个简单工具函数打印每个候选点的attack和defend肉眼排查哪一步分数异常。5.6 悔棋之后松散了复盘时棋子对不上现象悔棋后再走几步原本已经撤销的位置重新出现棋子或者先手优势局悔棋后胜率突然反转。原因悔棋只撤销了当前一方的棋子另一端棋子的落子记录没有入栈或者AI模式下AI落子没有调用steps.push_back导致栈顶顺序错位。解决统一约定——无论是玩家还是AI所有落子都压入steps栈。悔棋时按模式撤销人人撤销两步人机撤销玩家和AI各一步不要手软只撤销一枚。演示脚本里把这个动作固定住就不会现场翻车。注意这六条里最容易在答辩时被追问的是5.5。老师可能会问“AI怎么处理自杀式防守”这时候直接回答两步搜索的差值评估逻辑即可。其他几条更多是编码习惯问题提前写好就没事。6. 验收与答辩把演示流程和追问提前过一遍到交付阶段一套源码能不能过审关键在演示脚本。我一般会把验收流程固定成四条线避免现场手忙脚乱第一主菜单进入人人对战先手连续下两颗棋确认棋盘绘制、坐标映射与胜负第二从菜单中选择人机对战连续落子直到AI防守你已有的三连验证AI不只会进攻第三中途用0 0执行悔棋确认黑、白两枚都被撤销第四输入-1 -1认输确认认输分支正常退出。整个流程控制在5分钟以内演示时不要临时改参数。答辩老师通常集中在三个方向AI是怎么决策的、胜负判定在哪一层、能不能扩展图形界面。回答模板可以这样准备AI层不复杂核心是两个评分表加一个候选点集合一手棋的决策大约十几道条件判断胜负判定只在每次落子点向四个方向延伸统计复杂度与棋盘大小无关界面层只依赖标准输入输出后面要换成图形界面只需把printBoard替换成绘图函数数据结构和主循环都可以原样保留。还有几个加分项可以主动说两步搜索比单纯贪心强在哪直接演示AI能挡住对手活三解释评分表里100000、50000、8000的差值为什么要远大于普通棋型组合的累计AI才不会错判优先级再顺便把getCandidates的复杂度口头算一遍证明不是玄学。血泪经验是答辩前一定把AI步数的耗时测一次拖着不输出比判错更尴尬。从那以后我每次写这类对战小项目都会把“演示脚本加用例清单”先写好再开始改代码图形界面的移植也会提前确认字体与编码问题。这份源码按这套章节拆好了直接对照能跑通。希望帮到你。本文还有配套的精品资源点击获取