资讯详情 C++五子棋项目实战:从数据结构到AI博弈搜索的完整源码设计
📅 2026/10/17 4:50:54
简介面向C初学者与游戏开发爱好者这份五子棋源码包采用C实现支持人机对战与人人对战能帮助读者理解棋类游戏从数据结构设计、AI决策到界面交互的完整流程。压缩包共7个文件包含3个可执行程序、3个C源码文件和1个说明文档整体约106KB体量轻小适合逐行分析和本地运行调试。目前已有180人学习下载。源码价值体现在多个方面可对照学习棋盘、棋子等类的抽象设计博弈中AI落子策略规则评估或搜索算法玩家输入校验与事件处理以及图形界面绘制和状态刷新方法同时提供了鼠标版、键盘版、人对人版等不同实现形式便于观察同一游戏在不同交互方式下的架构取舍。通过这份代码还能巩固C面向对象编程和STL容器运用并为后续优化AI、增加悔棋或联网等功能提供起点。1. 为什么选五子棋做C项目一个能讲透算法和工程的双模式源码很多同学做C课程设计时最常听到的推荐题目就是五子棋但最后交上来的东西往往是在黑框里输坐标、回车落子的“控制台玩具”。真正让五子棋项目有价值的不是它“能跑”而是它天然覆盖了从数据结构到博弈搜索再到交互设计的完整链路。我的立场很明确与其去写一个花哨的管理系统不如认真做一套“人机OR人人”双模式的五子棋源码把棋盘模型、判胜逻辑、AI评分、搜索剪枝、界面交互全部打通。这篇方案就是围绕这样一套完整源码展开的每一章对应一个可以直接落地的模块剥开来看你会发现它的复杂度刚好够一份合格的课设又能支撑你答辩时往深里讲。这个项目选型反直觉的地方在于人人对战看起来简单但对交互和状态管理要求更高人机对战看着高大上核心其实是一层评估函数。两者放在一起才是完整的软件工程练习。2. 总体架构设计双模式、AI核心与界面层的职责划分2.1 棋盘数据模型与落子规则五子棋棋盘最常用的规格是15×15落子位置用二维数组天然合适。我给这套方案定义的数据结构很简单const int BOARD_SIZE 15; const int EMPTY 0; const int BLACK 1; const int WHITE 2; struct ChessBoard { int grid[BOARD_SIZE][BOARD_SIZE]; } board; // 落子时先校验 bool isValidMove(int x, int y) { if (x 0 || x BOARD_SIZE) return false; if (y 0 || y BOARD_SIZE) return false; return board.grid[x][y] EMPTY; }这里选择二维数组而不是一维数组原因是五子棋的判胜需要频繁沿四个方向扫描二维下标能直接映射到方向计算读代码的人一眼就能看明白。另一个常见选型是用vectorvector 但这会引入动态分配开销对固定棋盘来说反而绕。课程设计里如果用了“黑棋禁手”规则数据结构里还要额外记录“三三”“四四”状态但我的方案默认不启用禁手这个取舍在答辩时主动说清楚效果会比被动被问好得多。落子规则里还要明确先手黑棋先通常玩家执黑。在人人模式里黑棋和白棋分别由两个玩家控制AI模式下AI执白玩家执黑这个映射关系需要放到全局状态里否则后面做模式切换时很容易乱。2.2 人人对战的流程与判胜逻辑人人模式本质是一个双人轮流落子的状态机开局 - 玩家1落子 - 判胜 - 切换玩家 - 玩家2落子 - 判胜。用枚举管理状态比用int数字可读性强得多enum GameState { MENU, PVP_PLAYING, PVE_PLAYING, GAME_OVER }; GameState currentState MENU; int currentTurn BLACK; void switchTurn() { currentTurn (currentTurn BLACK) ? WHITE : BLACK; } void placeChess(int x, int y) { if (!isValidMove(x, y)) return; board.grid[x][y] currentTurn; if (checkWin(x, y)) { currentState GAME_OVER; return; } switchTurn(); }这里的关键点是判胜函数不要在整个棋盘上扫一遍而是只针对刚落下的棋子位置去检查时间复杂度能从O(n²)降到O(n)这个细节是答辩时的加分项。人人模式还需要处理“棋盘下满平局”的情况在checkWin返回false后需要再判断棋盘是否已满否则会出现双方都无法落子但游戏不结束的问题。人与人对战的另一个隐藏需求是“观感”。很多实现只是控制台打印一串坐标让用户输入“7,7”这种交互体验很差。架构上我们把“输入解析”和“落子逻辑”分开界面层负责把用户操作转成x、y规则层只认x、y这样后面换成鼠标点击时核心代码一行都不用改。2.3 人机对战AI决策模块的分层设计人机对战不是简单加一个“自动下棋”的函数而是要把AI决策拆成三个层次这样代码才清晰也方便后续升级评估层给定棋盘状态对每个空位打分判断这个位置的“价值”搜索层根据评估结果决定是直接取最高分还是需要向前推演几步用极小化极大搜索决策层把搜索得到的最优位置返回给游戏主循环。典型的主循环长这样void pveLoop() { if (currentTurn BLACK) { // 玩家输入 int x, y getPlayerInput(); placeChess(x, y); } else { // AI决策 ChessMove move aiDecide(); placeChess(move.x, move.y); } }AI决策函数对外的接口只有“输入当前棋盘和轮到谁返回落子坐标”内部怎么用贪心还是搜索完全不影响外部结构。这就是分层的好处你可以在第一版只写一个纯贪心的评分AI跑通了之后再给它加搜索算法而且不会破坏人人模式的代码。我给这套方案里的人机模式加了一个状态字段AiSide默认是WHITE。如果用户想反过来体验让AI执黑只需要改这个字段因为AI决策逻辑本身不关心颜色它只关心“哪个颜色是自己”和“哪个颜色是对手”。3. 核心算法实现从贪心到极小化极大与评估函数3.1 胜负判定算法四向扫描与模板匹配判胜算法看似简单翻车最多的也在这里。我的做法是定义四个方向向量从当前落子点出发沿正反方向同时扫描统计相同棋子的连续数量bool checkWin(int x, int y) { int directions[4][2] { {1, 0}, // 水平 {0, 1}, // 垂直 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; int color board.grid[x][y]; if (color EMPTY) return false; for (int i 0; i 4; i) { int count 1; int dx directions[i][0]; int dy directions[i][1]; // 正方向 for (int step 1; ; step) { int nx x dx * step; int ny y dy * step; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (board.grid[nx][ny] ! color) break; count; } // 负方向 for (int step 1; ; step) { int nx x - dx * step; int ny y - dy * step; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (board.grid[nx][ny] ! color) break; count; } if (count 5) return true; } return false; }这里有三个容易踩的细节。第一方向数组只定义了“趋势方向”比如水平方向是{1,0}那正方向就是向右扫负方向就是向左扫两边加起来的连续数量才是真正的连子数。第二循环里“先越界再判断棋子颜色”的顺序很重要一旦越界就break否则访问grid[-1][0]会直接数组越界崩溃。第三判胜时只需要从刚落下的棋子开始扫不需要全盘扫因为五子棋的胜负只会因为当前这一步改变。3.2 AI落子基于评分的贪心搜索第一版AI不需要把搜索想得太复杂用评分贪心就能做出“会堵会攻”的效果。核心思想是遍历所有空位对每个位置计算一个分数该位置分数由“如果下这里对己方能形成什么棋型”和“如果对方下这里会对我造成什么威胁”两部分组成int scoreForPosition(int x, int y, int myColor) { int oppColor (myColor BLACK) ? WHITE : BLACK; int totalScore 0; for (int i 0; i 4; i) { // 计算沿此方向myself的连子情况 totalScore evaluateLine(x, y, directions[i][0], directions[i][1], myColor); // 计算对手在该位置可能获得的威胁 totalScore evaluateLine(x, y, directions[i][0], directions[i][1], oppColor) * 0.9; } return totalScore; }evaluateLine函数会沿指定方向模拟如果把子下在这里这个方向上会形成“五连、活四、冲四、活三、眠三”等棋型然后给不同棋型赋不同的分数。这里有一个需要反复调参的权重表通常的做法是成倍递增而不是等差递增int getTypeScore(int chessType) { switch (chessType) { case FIVE: return 100000; case LIVE_FOUR: return 10000; case RUSH_FOUR: return 5000; case LIVE_THREE: return 1000; case SLEEP_THREE: return 300; case LIVE_TWO: return 100; default: return 0; } }评分建设计成指数级是因为一个“活三”或者“冲四”对棋局的影响远远大于几个“活二”的简单相加。这个AI有一个明显的缺陷它是“近视”的只能看到一步遇到两步之内的连续进攻往往反应不过来。但这已经足以通过大多数课程设计的基础要求而且代码量只有两三百行。3.3 进阶极小化极大搜索与alpha-beta剪枝想让AI更聪明就得让它“多想几步”。极小化极大搜索的思路是假设自己是AI对手会走对AI最不利的那一步所以AI要走一个让“对手最坏选择”中最好的位置。搜索树的层数就是“深度”深度越深棋力越强耗时也越长。下面这个函数是带剪枝的核心int minimax(int depth, int alpha, int beta, int currentColor) { if (depth 0) return evaluateBoard(currentColor); vectorChessMove moves generateCandidateMoves(); // 候选位置按评分排序剪枝效率更高 if (currentColor AI_COLOR) { int best -INF; for (auto move : moves) { board.grid[move.x][move.y] currentColor; int val minimax(depth - 1, alpha, beta, OPPONENT_COLOR); board.grid[move.x][move.y] EMPTY; best max(best, val); alpha max(alpha, best); if (beta alpha) break; // beta剪枝 } return best; } else { int best INF; for (auto move : moves) { board.grid[move.x][move.y] currentColor; int val minimax(depth - 1, alpha, beta, AI_COLOR); board.grid[move.x][move.y] EMPTY; best min(best, val); beta min(beta, best); if (beta alpha) break; // alpha剪枝 } return best; } }这里有一个必须强调的参数searchDepth。默认设成4再往上就是指数爆炸15x15棋盘上每一步的候选位置可能上百个深度4已经需要一些优化才能流畅运行。所以我在generateCandidateMoves里加了一个限制——只返回评分最高的前10个位置作为候选。这个技巧通常被称为“剪枝前置”它牺牲一点正确性换来巨大的速度提升。实现时你还需要一个整体评估函数evaluateBoard它会把棋盘上所有位置的棋型分数累加起来作为某一层搜索的“静态估值”。这段代码看起来不多但真正写起来容易绕晕的点在于搜索过程中要反复在递归里落子、撤销落子并且要分清当前深度下谁是“最大化方”谁是“最小化方”。我的经验是把AI和对手的颜色用全局常量定义好不要在递归参数里传来传去。4. 界面与交互实现控制台版和图形化版的双轨方案4.1 控制台版光标移动与坐标输入控制台版本胜在代码短、环境要求低很多宿舍用的老机器都能跑。交互上最劝退的写法是“请输入横坐标 纵坐标”正确的做法是把键盘方向键和空格键用起来。Windows下可以用_getch()读取方向键的扫描码#include conio.h #include windows.h void gotoxy(int x, int y) { COORD coord { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); } void drawBoardWithCursor(int cursorX, int cursorY) { system(cls); // 打印棋盘每个格子占3个字符宽度 for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (i cursorX j cursorY) { printWithHighlight(board.grid[i][j]); } else { printChess(board.grid[i][j]); } } printf(\n); } }控制台版要注意两个点。第一system(cls)清屏会让光标跳到左上角所以每次刷新前你需要记录玩家当前的光标位置刷新后重新定位。第二方向键的ASCII码是0xE0扩展码直接读取会读到两次输入通常的写法是如果读取到224或0再读一次得到真正的方向码。这个细节不处理好的话上下左右键会全部失灵。4.2 图形化版图形库选型与绘制流程图形化版本推荐使用某个Windows轻量图形库它把窗口创建、鼠标事件、画图都封装成了几个函数。我在这套方案里用的是最常见的那个图形库熟悉的人看到头文件和API就能类比初始化流程如下#include graphics.h void initGraphicWindow() { initgraph(600, 600); // 创建600x600的窗口 setbkcolor(RGB(255, 220, 180)); // 木纹底色 cleardevice(); drawBoardGrid(); } void drawBoardGrid() { setlinestyle(PS_SOLID, 1); setcolor(BLACK); int size 600; int step size / (BOARD_SIZE 1); int margin step; for (int i 0; i BOARD_SIZE; i) { line(margin i * step, margin, margin i * step, size - margin); line(margin, margin i * step, size - margin, margin i * step); } }鼠标点击转棋盘坐标是图形化版的核心映射逻辑。窗口坐标范围是0到600棋盘有15条网格线第一步要把像素点换算到离它最近的网格交叉点void onMouseClick(int mx, int my) { int step 600 / (BOARD_SIZE 1); int x round((mx - step) / step); int y round((my - step) / step); if (isValidMove(x, y)) { placeChess(x, y); drawChessAt(x, y, currentTurn); } }这里很容易犯的一个错误是直接用像素坐标减margin去除step没有对结果做“取整后判断距离是否真的在格子附近”的处理。比如玩家点击了两条线中间的位置它也会被当成某个坐标落子体验很怪。稳妥的做法是计算出候选坐标后再计算该坐标对应的像素点与实际鼠标点的距离如果超过step的0.4倍就认为是无效点击。4.3 悔棋、重开与模式切换的实现悔棋看似简单实际上要处理“人人模式下一步是让谁悔棋”和“人机模式AI的落子要不要一起悔掉”两个问题。我的做法是用一个历史栈记录每一步的落子struct ChessMove { int x, y; int color; }; vectorChessMove moveHistory; void undoLastMove(int steps 1) { for (int i 0; i steps; i) { if (moveHistory.empty()) return; ChessMove last moveHistory.back(); moveHistory.pop_back(); board.grid[last.x][last.y] EMPTY; } currentTurn moveHistory.empty() ? BLACK : moveHistory.back().color BLACK ? WHITE : BLACK; }人机模式下玩家悔棋时通常应该连AI的上一步一起悔掉即每次悔棋撤销两步这样才能回到玩家落子前的局面。我在界面层做了一步封装如果当前是PVE模式点击悔棋按钮就直接调用undoLastMove(2)否则调用undoLastMove(1)。这个细节如果不做玩家会发现自己悔了一手之后AI立刻又在原来位置周围补一手其实局面根本没变化。重开就是一键清空棋盘和重置状态逻辑简单但要注意内存里的moveHistory也必须清空否则悔棋时会恢复到“上一次对局”的步骤。模式切换一般放在开始菜单用一个整数变量gameMode分别表示PVP和PVE切换时要重新初始化所有数据不能只改标志位。5. 常见问题与避坑指南写五子棋最容易翻车的五个点5.1 判胜漏掉斜线方向现象明明五个子斜着连成一条线程序却判定没有获胜必须再下一手才突然结束。原因方向数组只写了水平和垂直方向漏了两个对角线的方向向量。更隐蔽的情况是写了{1,1}但忘了写{1,-1}导致另一种斜线方向永远检测不到。解决判胜的方向数组必须包含四组向量并且在写完代码后手动构造一个“主对角线五连”的棋盘和一个“副对角线五连”的棋盘做测试。我一般会写一个临时测试函数直接向board里塞值不走正常落子流程这样能隔离出问题是不是出在判胜逻辑上。5.2 AI只会进攻不会防守现象AI疯狂堵自己的活三但对对手已经在冲刺的“四连”视而不见或者反过来只盲目防守而放弃自己已经快赢的棋。原因评分函数里只计算了“我下这个位置能形成什么棋型”没有计算“如果我下这个位置对手将失去什么机会”。更准确地说是对手棋型的评估权重太小或者干脆漏了。解决在每个位置的评分里用同一个评估函数分别评估“我在这形成活四能得多少分”和“对方在这形成活五得多少分”然后前者权重设为1.0后者权重设为0.9到1.0之间。注意防守权重不能大于进攻权重否则AI会很怂明明能赢却去堵一个不重要的地方。5.3 递归搜索卡得完全不能动现象人机模式下AI走一步要卡好几秒或者干脆程序无响应只能强行关闭。关掉搜索深度之后又变回一个傻白甜AI棋力大打折扣。原因没有限制搜索的候选分支数量也没有用alpha-beta剪枝。深度4的情况下每个节点假设有100个候选位置那搜索量就是1亿的量级任何计算机都扛不住。解决两层优化。第一在进入minimax之前先用评分函数把所有空位排个序只取前10个得分最高的位置作为候选把搜索树的分支因子从100砍到10。第二给递归加上alpha和beta两个参数一旦当前分支的上界小于父节点的下界就立刻break。这两步做完深度4的搜索能在1秒内返回结果。5.4 控制台界面刷屏闪烁、光标乱跳现象刷屏之后整个棋盘从头到尾打印一遍玩家正在输入的方向键被中断屏幕闪得眼睛疼。原因system(cls)强行清空整个屏幕然后printf一行一行画棋盘这个过程中光标位置全部丢失输入状态也被打断。控制台看似简单但“清屏重绘”这招在输入循环里非常不好使。解决不改动整个棋盘只在“上一次棋子”的位置和“光标当前位置”做局部刷新。具体做法是用SetConsoleCursorPosition把光标移到那个格子然后只重绘那一个位置的字符这样既不会闪屏也不会干扰输入。如果坚持全屏重绘至少要在重绘前把光标坐标记录下来重绘后用gotoxy恢复。5.5 人人模式和人机模式的落子顺序紊乱现象开始菜单选了人机模式但AI没有自动落子玩家连下两手后AI才慢悠悠走一步或者从人机切回人人之后黑棋变成了AI在控制。原因全局状态里只存了currentTurn没有单独记录“模式对应的玩家颜色”。比如PVE模式下AI每回合都需要被主动调用一次但主循环忘了在AI回合里调用aiDecide而是走进了玩家输入的分支。解决把游戏模式和控制角色设计成一个显式的状态。我用一个枚举TurnType来表示当前动作类型它的值可以是HUMAN_INPUT或AI_INPUT然后在主循环里根据当前TurnType选择走玩家分支还是AI分支。切换模式时统一调用resetGame()把currentTurn、moveHistory、棋盘全部重置一遍避免历史残留。6. 验收与答辩指南一套照做就能演示的检查清单6.1 五分钟验收清单在答辩前你只需要照着这张表走一遍就能覆盖绝大多数会被问到的问题验收项操作步骤预期结果人人模式基本落子选“人人模式”轮流用鼠标或方向键落子黑棋先手黑白交替已经落过棋子的位置不能再落五连判胜在一个方向上摆出连续五个同色棋子游戏结束显示该颜色获胜棋盘上出现赢家提示斜线判胜在对角线方向摆出五个同色棋子正确判胜不是只判水平方向棋盘下满平局摆满整个棋盘并且没有五连游戏宣布平局而不是无法操作人机模式AI落子选“人机模式”玩家落子后等待AIAI在合理时间内1秒内自动落子且能主动堵玩家的活三悔棋功能在任意模式下连下数手后点悔棋人人模式回退一步人机模式回退两步局面与之前一致重开功能对局中途点重开棋盘清空轮转到黑棋历史记录清空6.2 答辩时怎么讲才能不被追问卡住大多数答辩老师不会对你的代码逐行看他们关心的是“这个东西是不是你写的”和“你遇到最大的问题是什么”。我建议把讲解顺序定为先讲数据结构和判胜算法再讲AI评分最后提一句搜索剪枝。不要一上来就讲界面界面人人都会做。在讲到AI时主动说出“我这个AI是贪心评分后续可以扩展成极小化极大搜索”这句话会引导老师往你准备好的方向问。如果老师真的追问了搜索实现你再说深度、候选分支、alpha-beta剪枝这些点我们已经在这个方案里全部实现了。回忆我自己做这个项目时最血泪的经验第一版判胜函数只写了水平和垂直方向自己和人机对战的时候AI不会下斜线我还以为是AI笨后来才发现是判胜直接不认斜线五连。那次之后我养成了一个习惯凡是写棋类判胜第一件事就是把四个方向向量列出来其次才写循环。从那以后我每次做这类项目都会强制走一遍“边界输入测试法”——在棋盘角落、边缘和正中间各摆一次五连验过之后再谈其他功能。希望这套方案能帮到你让你从“能跑”走到“会讲”。本文还有配套的精品资源点击获取