首页 / 资讯中心 / 文章详情

C++五子棋源码剖析:从评估函数到AI决策的完整实现

C++五子棋源码剖析:从评估函数到AI决策的完整实现 ★ FEATURED ARTICLE
简介C五子棋源码项目面向C初学者与游戏开发入门者演示人机AI对战与人人同屏对战两种模式。压缩包共7个文件约106KB包含3个exe可执行程序、3个cpp源文件及1份说明txt可直接运行并对照源码学习。已有180人学习下载。项目覆盖鼠标版、键盘版、人机版和人人版方便比较不同交互方式的输入处理、棋盘状态更新和AI走子逻辑。通过阅读代码可以具体掌握棋盘数据结构设计、落子合法性校验、胜负判定以及Minimax/Alpha-Beta等搜索算法在小型游戏中的实际落地。界面渲染、事件响应与C面向对象设计思路也贯穿其中适合用于课程设计、C综合练习或图形库游戏入门帮助开发者快速建立完整项目认知。1. 别急着加搜索层数这个五子棋源码的真正价值在于把 AI 落子的每一步拆给你看拿到这份 C 五子棋源码时我本以为它又是一个把棋盘用二维数组存起来、然后无脑遍历判赢的课设作业。真正拆开读了一遍才发现它的核心并不在胜负判断而在于人机对战那一侧的评估函数与搜索流程AI 每走一步都要把当前棋盘上所有可落点扫一遍用权值表给每个点打分再结合一层或两层前瞻决定落点。这个思路跟很多入门教程里教的“用随机数或简单挡堵”完全不同它是一套可以继续深挖的决策框架。这套源码适合两类读者一类是想把 C 语法落到实际项目里的学习者可以借着它搞懂二维数组、坐标映射、函数拆分和状态管理另一类是想做棋类 AI 但不想从零开始的开发者可以直接把评估函数和搜索框架抠出来改一改就能用到黑白棋、象棋残局等类似场景。它不依赖图形界面运行起来就是控制台下的数字棋盘反而方便你观察每一步的决策数据。如果你正在找一份能跑起来、能读懂、能动手改的棋类游戏源码这份资源值得花一个晚上慢慢拆。2. 整体架构与运行链路main.cpp、board 状态与搜索三部曲2.1 源码包里的文件布局与职责分配拿到源码包后先别急着编译我习惯先把文件结构扫一遍。这个项目的典型布局一般长这样Gomoku/ |-- main.cpp // 程序入口负责菜单交互与游戏模式选择 |-- board.h // 棋盘数据结构与坐标宏定义 |-- board.cpp // 棋盘初始化、落子、打印 |-- ai.h // AI 接口定义 |-- ai.cpp // 评估函数与搜索逻辑 |-- judge.cpp // 五子连珠判断 |-- README.md // 编译说明与操作规则这里的头文件与源文件分离是值得学习的习惯。board.h 里你大概率会看到类似#define BOARD_SIZE 15这样的宏定义以及一个int board[15][15]的全局数组。很多初学者会把所有逻辑塞进 main.cpp但这个项目把棋盘操作和 AI 决策拆成了独立模块意味着你可以单独替换 AI 而不影响棋盘逻辑。编译时通常不需要额外链接第三方库因为控制台版本的输入输出全部依赖 C 标准库。如果你用的是 VS直接新建空项目然后把 .cpp 文件拖进去编译如果你在 Linux 或 macOS 下用 g一条命令就能编出来具体参数我放在后面运行章节里。2.2 为什么选控制台而不是图形界面把决策过程暴露出来我拆过的很多棋类项目一上来就上 Qt 或 EasyX界面确实好看但核心逻辑往往被事件循环和绘图代码淹没。这个源码选择控制台输出反而把“AI 如何思考”这件事暴露在了你面前。运行起来后棋盘会以行列坐标的形式打印在终端里玩家输入类似7 7的坐标完成落子AI 的落子也会以同样格式输出。在 AI 模块里每个可落点都会被赋予一个分数这个分数会写进一个临时数组。如果你在 ai.cpp 里加一行打印代码就能看到 AI 对每个坐标的打分情况比如某个点因为能形成活四而得了高分另一个点因为只是孤立一子而得分很低。这种可观测性是控制台版本最大的优势它逼着你从“功能能跑”走向“决策可解释”。2.3 模块调用关系从玩家输入到 AI 落子发生了什么整个程序的调用链并不复杂核心可以概括为三步。第一步main 函数读入模式选择如果是人机对战且轮到 AI 走棋程序调用aiGetMove(board)第二步ai.cpp 内部先遍历棋盘上的空位调用评估函数给每个空位打分再取最高分对应的坐标返回第三步main 拿到坐标后调用placePiece(board, x, y, player)落子最后调用checkWin(board, x, y)判断胜负。这里有一个常见的设计毛病必须提一下很多课设版本在 AI 打分时把己方进攻和对方防守混在一起导致 AI 既挡不住三连也形不成自己的攻势。这个源码如果写得合格评估函数内部会把“我方棋型分”和“对方棋型分”分开计算然后以myScore * attackWeight rivalScore * defenseWeight的形式加权attackWeight 和 defenseWeight 默认都是 1.0这样 AI 才既会攻也会守。后面章节我会把这一步的实现细节完整拆开。3. 核心模块拆解落子、判赢与 AI 评估函数的完整链路3.1 棋盘表示与落子校验数组越界是头号隐患棋盘用 15×15 的二维数组表示0 表示空位1 表示黑子-1 表示白子。这个设计非常节省内存也方便判断胜负。你需要重点关注placePiece函数里的边界校验因为控制台输入是不可控的玩家可能输入20 20也可能输入-1 5甚至可能重复落子。bool placePiece(int board[][BOARD_SIZE], int x, int y, int player) { if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE) { return false; // 坐标超出棋盘边界 } if (board[x][y] ! 0) { return false; // 该位置已有棋子禁止覆盖 } board[x][y] player; return true; }这里用的是二维数组接收参数时的标准写法第一维度可以省略大小第二维度必须显式指定。在 main 函数里调用时你传的是board这个二维数组名它会自动退化为指向数组的指针。返回值采用 bool 很关键因为 main 里要根据落子是否成功来决定要不要重新显示菜单或提示重新输入。如果玩家输入非法坐标程序应该循环读取而不是直接退出。我见过有些版本直接在落子失败时返回 -1 并终止游戏这属于交互设计上的偷懒。一个合格的版本会一个while循环反复提示直到输入合法的空坐标为止。检查这段代码时我建议你优先看它有没有处理输入流失败的情况比如玩家输入了字母而不是数字。3.2 五子连珠判断八方向遍历比穷举更优雅胜负判断是五子棋项目里最容易写“笨”的地方。最粗暴的写法是落子后遍历整个棋盘检查每一行、每一列、每个对角线是否有连续五子。但这个源码通常在judge.cpp里采用以当前落子为中心、向八个方向扩展的方式代码更短性能也更好。bool checkWin(int board[][BOARD_SIZE], int x, int y) { int directions[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int i 0; i 4; i) { int count 1; for (int step 1; step 5; step) { int nx x directions[i][0] * step; int ny y directions[i][1] * step; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (board[nx][ny] ! board[x][y]) break; count; } for (int step 1; step 5; step) { int nx x - directions[i][0] * step; int ny y - directions[i][1] * step; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (board[nx][ny] ! board[x][y]) break; count; } if (count 5) return true; } return false; }这段代码的关键是只用了四个方向向量因为每个方向都向两侧扩展。比如{1, 0}表示横向先往右数同色棋子再往左数把两边的数量加起来再加上当前棋子本身得到一条线上的连子数。另一个方向{1, -1}处理的是从左上到右下的斜线配合{1, 1}覆盖从右上到左下的斜线。边界处理值得反复看在往某个方向扩展时一旦发现下一格越界就立刻中断而不是继续访问未定义的内存。很多老师喜欢在这道题上测试边界坐标比如在 (0,0) 落子后横向连成五子此时向左扩展第一次就会越界。代码里的break保证了不会读到board[-1][0]这是 15×15 棋盘能稳定运行而不崩溃的关键。3.3 AI 评估函数权值表与棋型枚举的实战写法这是整个源码最值得单独拎出来讲的部分。AI 的评估函数会扫描每个空位四周的棋型比如“活三”“冲四”“眠三”等然后给这些棋型赋予权值。一个活四的值可能高达 100000一个冲四值 10000活三值 5000以此类推。这个权值表的数值直接决定了 AI 的下棋风格活四权重越高AI 越倾向于直接获胜而不是围堵对方。int evaluatePoint(int board[][BOARD_SIZE], int x, int y, int aiPlayer) { int humanPlayer (aiPlayer 1) ? -1 : 1; int score 0; // 模拟 AI 在此处落子统计四个方向上的棋型 board[x][y] aiPlayer; for (int dir 0; dir 4; dir) { int cnt countDirection(board, x, y, dir, aiPlayer); score shapeScore(cnt); } board[x][y] 0; // 模拟玩家在此处落子AI 需要防守因此同样计分 board[x][y] humanPlayer; for (int dir 0; dir 4; dir) { int cnt countDirection(board, x, y, dir, humanPlayer); score shapeScore(cnt) * 0.9; // 防守权重略低于进攻 } board[x][y] 0; return score; }这里的防守权重 0.9 是我比较推荐的起步值。如果设成 1.0AI 经常会过度防守玩家在角落放一颗子AI 就跑到角落去贴完全不管自己在中腹的棋型优势。设成小于 1.0 之后AI 会优先发展自己的攻势只有对方形成实质性威胁时才转向防守。你可以在 ai.cpp 里找到这个系数想调 AI 的攻击性就把它改小比如 0.6想让 AI 更保守就改到 1.1。countDirection函数要做的事情是沿指定方向统计连续同色棋子的数量并且判断两端是否被堵住。这里又分为“两端都没堵”的活棋、“一端堵死”的眠棋、“两端都堵”的死棋它们的权值差异非常大。一个连续三子如果是活三下一步就能变活四几乎必胜但如果是眠三对方堵住一端后威胁小得多。好的评估函数不会只看连子数量还会看两端状态这部分就是五子棋 AI 的玄学所在调试起来全靠经验。3.4 搜索与决策要不要上 Alpha-Beta 剪枝这份源码如果只做一层评估AI 的棋力大约相当于普通爱好者入门水平能挡住明显的三连也能自己凑出一些进攻机会但缺少“先算几步再决定”的韬略。要实现更强的人机对战常见做法是在评估函数外面套一层极小极大值搜索并用 Alpha-Beta 剪枝减少计算量。我拆这个源码时发现一个有意思的权衡AI 每一手棋要对 15×15 棋盘上大约 200 个空位做评估每个评估又要计算四个方向的棋型。如果只做一层评估运行时间在毫秒级肉眼无感知。但如果搜索两层计算量会变成 200 乘 199 次评估此时即便每个评估很快总耗时也可能达到一秒量级玩家会明显感觉到卡顿。因此这个项目的源码通常默认只做一层评估。你如果想增强 AI不必立刻上两层搜索而是可以先用“跳跃采样”缩小候选点只评估距离已有棋子两格以内的空位把候选点降到 50 个以内再做两层搜索。这种优化在资源包里不一定有实现但这是我对它做二次开发时最先会动手的方向。4. 常见问题与排查编译失败、乱码、AI 不落子的踩坑记录4.1 现象VS 编译报一堆 C4996 或 C2664 错误当你在 Visual Studio 里直接打开源码编译时经常看到类似C4996: scanf: This function or variable may be unsafe的警告或者错误列表里出现C2664: cannot convert argument 1 from int [15][15] to int **。初学者遇到这些信息往往一头雾水觉得是源码坏了。原因要分开说。C4996 是因为微软把标准 C 函数视作不安全推荐你用scanf_s替代但很多棋类课设源码写的是scanf换到 VS 就报警C2664 则是因为函数声明写作void printBoard(int** board)但二维数组不能直接隐式转换成二级指针这是 C 的类型系统在严查数组与指针的关系。解决方式是我在拆这种源码时的保留项目。第一类问题在文件开头加一行#define _CRT_SECURE_NO_WARNINGS就能让 VS 闭嘴第二类问题把函数参数改成int board[][BOARD_SIZE]同时把 BOARD_SIZE 定义成常量这样数组类型在编译时已知参数匹配就不会再报错。如果你用的是 g这两类问题通常不存在Linux 下对 C 风格函数宽容得多。4.2 现象终端输出中文乱码棋盘整个挤成一行运行起来后本该展示为方格的棋盘变成了乱码符号或者中文菜单全部显示成问号。这种情况在 Windows 控制台下特别常见因为源码文件保存的编码和终端活动代码页不对齐。如果源码是 UTF-8 编码而 Windows 控制台默认用 GBK 代码页中文就会变成???或乱码。解决方式不是在代码里加复杂的编码转换而是最小化改动。你可以在 main 函数最前面加SetConsoleOutputCP(CP_UTF8)并包含windows.h这会强制控制台使用 UTF-8 输出如果不想动代码也可以在运行前在终端执行chcp 65001切代码页。我用 g 在 Linux 终端跑通常没有这个问题但如果你在 PowerShell 里跑 Windows 编译出的 exe还是要留意编码。如果你看到棋盘没有乱码但完全挤成一行说明代码里打印棋盘时没有处理列间距。正常实现会在每行数字后面打印一个空格或制表符比如cout board[i][j] 如果只打了 board[i][j]15 列的数字会连在一起。这是源码里一个很小的细节但对新手排查很干扰。4.3 现象轮到 AI 时程序卡死或长时间无输出有一种比较隐蔽的坑AI 模块里不小心写了死循环比如在遍历候选空位时while (iter BOARD_SIZE * BOARD_SIZE)内部没有更新迭代变量或者评估函数对它自己刚摆下去的子重复扫描。你运行到某一手时程序会像卡住一样控制台没有任何输出CPU 占用拉满。解决时先在 ai.cpp 里加临时调试输出在aiGetMove的入口打印进入函数的日志在循环末尾打印候选点数量。如果日志显示进入函数后没有任何候选点输出那基本可以确定评估循环没有跑完如果函数根本不进入那问题在 main 里调用 AI 的位置比如玩家模式和人机模式的玩家编号写反了。我见过一个版本把 AI 的玩家编号写成了 1结果轮到 AI 时它永远找不到属于自己的棋子每次评估都返回 0 分最后选了 (0,0) 这个死点。4.4 现象明明五子连珠却不判赢这类问题最常见的原因是落子后没有立刻调用checkWin而是等下一轮才检查结果在边界情况下漏判。另一种可能是在checkWin里把当前落子位置固定设为某个颜色比如写死board[x][y] 1导致黑棋赢棋能判断白棋赢棋永远返回 false。排查时给checkWin传入的x, y就是刚落下的棋子的坐标函数内部应该使用board[x][y]作为匹配颜色而不是写死某个玩家编号。这个设计说到底是“判断当前这一步是否形成五连”而不是“遍历整个棋盘找有没有五连”。你要测试白子连五时需要专门构造白方坐标并让该坐标的board[x][y]等于 -1如果写死为 1就会出现摆出五颗白子仍判负的怪状。5. 运行与调试把源码从下载变成可复现的本地项目5.1 编译环境的两个选择与具体命令我在本地一般用两种方式编译这类源码。如果你在 Windows 上用 Visual Studio新建空项目、把全部 .cpp 文件加入“源文件”过滤器、点击本地 Windows 调试器就能跑起来。如果你更习惯命令行用 MinGW 或 WSL 里的 g 编译会更直接g -o gomoku main.cpp board.cpp ai.cpp judge.cpp -stdc11 ./gomoku这条命令会一次性完成编译与链接。-stdc11是因为源码里可能用了 C11 的特性比如nullptr或范围 for 循环不加这个参数在某些旧版本 GCC 下会报警告。-o gomoku指定输出可执行文件名如果你在 Windows 下用 MinGW命令末尾还会自动生成gomoku.exe。编译成功后运行程序会先打印菜单让你输入 1 来选择人机对战输入 2 来选择人人对战输入 0 退出。进入人机模式后它大概率还会问你先手还是后手这是 AI 模块里一个重要的分支先手 AI 的思路偏向进攻后手 AI 偏向防守。5.2 边界测试用例不需要打完整局棋也能验证逻辑测试这种源码最担心的是试了几手之后没发现 bug结果下到关键时刻才崩。我一般会构造几组快速用例来验证核心函数。第一组是在边角测试 win 判断让 AI 在 (0,0) 到 (0,4) 连下五子触发 checkWin 返回 true第二组是测试不越界连续下-1 0、15 15、7 7三次只有第三次应该成功第三组是测试防守权重让玩家在关键位置形成活三然后调用 AI 的下棋函数观察 AI 是否立刻堵住一端。这种验证其实不需要交互式输入你可以在 main.cpp 里临时写几行硬编码数据把 board 数组初始化成指定状态然后直接调用checkWin或aiGetMove用cout把结果打出来。验证完后删掉测试代码。这是把源码吃透的捷径也是给二次开发加单元测试的第一步。5.3 运行期调试技巧在 AI 打分数组里埋一个观察窗提到调参我需要给一个具体操作在 ai.cpp 的评估循环里加一个全局调试开关比如bool debugAI true当它为 true 时AI 每算完一个空位就把坐标和得分打印出来。if (debugAI) { printf(pos(%d,%d) score%d\n, x, y, score); }这时运行人机对战你会发现 AI 在开场时会选择棋盘正中央的点因为那是唯一同时占据四条对称轴的空位得分天然最高。之后它的选择会逐渐转向靠近已有棋子的区域因为空无一子的区域得分会迅速归零。这个打印功能能帮你直观理解权值表的影响把活三权重调大后AI 会更早地组成连子而把防守权重调大后你摆一个活二它就会上来挡。如果输出信息太多看不清可以把debugAI改成只打印得分最高的前五个点这样你就能知道 AI 的取舍依据。6. 二次开发把控制台五子棋改造成可调参、可复用的棋类 AI 框架想要让这份源码变成你自己的东西最有效的方向不是重写一版带界面的五子棋而是从 AI 模块的稳定性入手。我给三个具体改进方向第一个是把评估函数里的每个权值抽成常量数组第二个是给 AI 加上“防守半径”概念第三个是增加悔棋功能的历史栈。权值常量数组的改法是这样在 ai.cpp 顶部定义一个结构体或类把活四、冲四、活三、眠三、活二这些棋型的分数集中放在一块。这样想调 AI 风格时改数组值就够了不用在函数内部到处找散落的魔法数字。struct ShapeScore { int liveFour 100000; int rushFour 10000; int liveThree 5000; int sleepThree 500; int liveTwo 500; };所谓“防守半径”是很多棋类 AI 调优里的常用技巧评估空位时只计算距离已有棋子两格以内的点超出范围的空位直接跳过不参与评分。这样既能大幅减少评估计算量又不会漏掉关键攻防点。改动的方式是在遍历空位的循环里加一个距离判断正好对应我前面说到的两层搜索提速方案。加悔棋功能则需要引入一个历史栈每次落子前把当前坐标和玩家编号压栈悔棋时弹出栈顶并恢复棋盘。这一步会动到 main 函数的交互逻辑但也是理解整个项目状态管理的好机会。实现时要注意棋盘的深拷贝问题如果栈里存的是整个二维数组推入一次会复制 225 个 int对 15×15 的棋盘来说负担不大可以直接这样写。我最想提醒你的是在做完以上任意一项改动后重新跑一遍我前面给出的边界用例尤其要验证 AI 在防守半径缩小后不会对远离棋子的位置完全无视。这个坑我自己踩过为了提速把半径调成 1结果 AI 在玩家下出跳三时没有任何反应因为它评估范围内根本看不到那颗棋子。从那以后我每次改评估逻辑都会强制把这组测试跑一遍边角连胜、非法坐标、活三是否防守、AI 是否卡死再决定要不要收工。希望这个调试习惯能帮你在改这套源码时少走一点弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站