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

C语言五子棋小游戏:从数组下标到AI的完整实现与调试要点

C语言五子棋小游戏:从数组下标到AI的完整实现与调试要点 ★ FEATURED ARTICLE
先说我遇到的一件真实事。一个同学交上来一份C语言五子棋小游戏代码棋盘能画落子能显前八手一切正常到第九手就开始不对劲要么多跳一步要么直接黑屏退出。我帮他查了两个小时问题并不出在“五子”本身而是三个非常基础的环节坐标没有从用户习惯换算成数组下标、输入缓冲残留了回车符、胜负判定函数越界读了棋盘外的内存。这三件事单独看都不难但放在一起足以让一份“看起来差不多”的代码彻底跑不稳。这篇内容不打算贴一个完整大代码就算完而是把C语言五子棋小游戏代码从数据模型、落子校验、胜负判断到主循环、简单AI一层层拆开告诉你怎么写出自己能一眼看懂、也敢拿去交差的版本。适合刚学完数组和函数的同学也适合想给课程设计加个“AI对手”加分项但不想把代码搞成两三千行的朋友。1. 棋盘数据模型从“画格子”到“棋局状态”的设计1.1 为什么我坚持用固定大小的二维int数组很多初学者会想棋盘能不能做成动态的能不能用链表存储如果题目是“用C语言写数据结构”这些想法当然有价值但五子棋小游戏的核心目标是训练数组、循环、函数封装和基础算法。固定15×15就是最常见的标准盘面我在项目里直接用一行宏定义#define N 15后续想改成19路围棋盘只改这一行所有函数都自动适配。用int board[N][N]而不是char board[N][N]有三个实际理由第一int预留了扩展空间。0表示空、1表示黑方、2表示白方这个模型可以一直用下去。将来如果加悔棋标记、加“禁手”规则、加局面评估的分值改起来都比char顺手。第二调试方便。在调试器里观察一个int数组数值清清楚楚是0、1、2而char数组偶尔会混进ASCII码的显示值容易看花眼。第三二维数组在内存中是连续分布的传给函数、打印棋盘、保存棋局快照都很自然。更好的一点是它逼着你理解C语言数组传参的本质board[row][col]其实就是*(*(board row) col)这句话虽然绕但每写一次五子棋你对指针和数组的亲近感就会深一层。1.2 用户坐标和数组下标的“一减之差”这是整个项目中出错率最高的一个地方。玩家坐在屏幕前看到的是从1到15的行号和列号输入“8 8”指的是第8行第8列。但在C的二维数组里board[8][8]是第9行第9列。如果你不做换算用户输入“1 1”时你其实把棋子下在了board[1][1]左上角board[0][0]永远空在那儿棋盘整体向右下偏移了一格。偏移在中间看起来没什么一旦用户输入边界值“15 15”你的代码访问的是board[15][15]而合法下标只到board[14][14]。这一下就直接越界轻则读到脏数据重则程序崩溃。正确做法很简单解析完用户输入后先做一次减法再进入任何棋盘操作。用户看到的坐标实际数组下标1 1board[0][0]8 8board[7][7]15 15board[14][14]我见过很多代码把“减去1”这一步写在显示函数里打印时多加1读取时又多减一次最后两边一凑又对了。这种写法碰巧能运行但逻辑是拧巴的。正确的职责划分是存储和计算一律用0基下标只有在打印棋盘标题和提示用户时才显示成1基数字。这样所有函数内部的心智模型是统一且一致的。1.3 打印棋盘用ASCII符号避开终端的显示坑控制台打印棋盘看起来是一件小事实际上隐藏着一个跨平台问题。很多教程喜欢用●和○表示黑白棋代码在学校Windows机上跑一旦终端代码页不是UTF-8这些符号就会变成乱码方块。我就见过明明自己机器上显示正常交到另一台机器却满屏问号的尴尬场景。稳妥的办法是使用纯ASCII字符空位用.黑棋用X白棋用O。这样在任何终端、任何编码下都不会乱。如果你确实想做得更美观可以保留●○但要明确自己只在支持UTF-8的Linux终端或现代Windows Terminal里运行这属于权衡取舍不属于必选项。打印棋盘时我喜欢把行号列号一起打出来。列号一行单独打印棋盘每行前面再打印行号这样玩家不需要自己去数格子void print_board(int board[N][N]) { printf( ); for (int j 0; j N; j) { printf(%2d , j 1); } printf(\n); for (int i 0; i N; i) { printf(%2d , i 1); for (int j 0; j N; j) { if (board[i][j] 0) printf( . ); else if (board[i][j] 1) printf( X ); else printf( O ); } printf(\n); } }这里每个符号都占三个字符宽整体网格是对齐的。用printf(%2d )控制列号宽度目的是让列号为10到15的数字也能对齐。打印棋盘这一项虽然简单但直接影响游戏体验。一个格子错位的棋盘玩起来第一反应就是“这代码写得糙”。2. 落子函数与输入解析稳定性的一半精力都在这里2.1 落子函数要同时守住“越界”和“占位”两道闸落子操作看起来就是一个赋值语句但你不应该直接在主函数里写board[row][col] player。正确的做法是封装一个独立的落子函数把所有合法性检查集中在一处int drop(int board[N][N], int row, int col, int player) { if (row 0 || row N || col 0 || col N) { return 0; } if (board[row][col] ! 0) { return 0; } board[row][col] player; return 1; }返回值1表示落子成功0表示失败。主循环拿到0就知道需要重新输入不会误切换玩家。这里有一个很多人忽略的细节检查的顺序。必须先判断行列是否越界再去访问board[row][col]。如果你先读board[row][col]再检查越界当用户输入15 15的时候程序已经在下标15的位置读了一次内存行为就不可控了。C语言的数组越界不保证立刻崩溃它可能是一块可读的脏数据也可能是破坏其他变量的元凶。调试这类问题时最头疼的地方在于程序不一定当场报错而是“不知道什么时候就行为诡异”。2.2 输入解析不要用scanf吃换行用fgets加sscanf更稳几乎每个写过控制台游戏的人都踩过这个坑scanf(%d, row)之后输入缓冲区里还留着一个换行符。下一次你用getchar()或者scanf(%c)时读到的就是那个换行游戏会莫名其妙“空跳”一步。有两种解决思路。第一种是每次读完后写一个清空缓冲区的循环while (getchar() ! \n);这个方案能用但容易出边界问题如果用户输入的是“15 15\n”scanf读走两个整数后缓冲区里确实只剩换行清空是干净的。可如果用户输入了“15 15 99\n”多出来的“99”也会被循环读掉这造成第三种行为用户手滑多输了一个数字你的程序直接当成无事发生。理论上是安全的但实际交互里会让玩家觉得“我明明输入了三次为什么只生效两次”。我更喜欢第二种方案用fgets读一整行再用sscanf解析char line[64]; if (fgets(line, sizeof(line), stdin) NULL) { continue; } // 把逗号替换成空格兼容“8,8”和“8 8”两种输入习惯 for (char *p line; *p; p) { if (*p , || *p ) { *p ; } } int row, col; if (sscanf(line, %d %d, row, col) ! 2) { printf(输入格式不对请输入行号和列号比如 8 8。\n); continue; }这种方式有两个直接好处。第一整行都被fgets读走换行符不会再残留在缓冲区里后面无论用什么输入函数都不会串位。第二sscanf失败时你能立刻知道输入无效不会让缓冲区变成一个半消耗状态。2.3 在循环中组织“输入-校验-落子”的完整顺序到了主循环里这一段输入逻辑的正确顺序是打印棋盘提示当前轮到谁fgets读取一行解析行列号转换成0基下标调用drop落子如果落子失败提示并用continue跳过下面的胜负判断直接回到第1步。关键一点在于落子失败时不能切换玩家。这个顺序写错了就会出现“玩家1下了一个无效位置接着轮到玩家2走但棋盘没变化”这种让人抓狂的逻辑错误。初学者最容易犯的错误是把drop返回值的判断放在胜负判断之后导致无效输入也能触发一次赢棋检查虽然最终不会判定胜利但状态已经被污染了。3. 胜负判定只在落子后扫描邻域四个方向一次算清3.1 为什么不需要每次全盘扫描最直观的胜负判定做法是每次落子后用一个双重循环把整个棋盘扫一遍检测是否存在五连。这种做法没错但效率太差。15×15的棋盘共有225个位置每一步都要全部遍历一次双方下到一两百手时累计扫描的次数很可观。更重要的是它让代码里多了一层和游戏规则完全无关的复杂度。正确的思路是只有刚落下棋子的位置才可能形成新的获胜连线。之前没形成五连说明其他位置都是安全的这一步落下去变化的只有当前棋子的四个方向。所以我们只需要以这个点为中心检查水平、垂直、主对角线、副对角线四条线。3.2 用方向数组把四个方向的判定统一成一段代码我见过很多人把四个方向的判定写成四段几乎重复的代码每段复制修改一下。这样当然能工作但代码会变得很长而且容易在复制时漏改某个边界判断。统一写法是利用方向数组把“水平、垂直、两条斜线”表达成四个方向向量const int dirs[4][2] { {0, 1}, // 水平向右 {1, 0}, // 垂直向下 {1, 1}, // 主对角线 {1, -1} // 副对角线 };判断某个方向是否连成五子只需从当前点向正方向和反方向分别延伸计数把两个方向的连续同色棋子数加起来再加上当前这一子int count_line(int board[N][N], int row, int col, int dx, int dy, int player) { int cnt 1; for (int k 1; k 5; k) { int nr row dx * k; int nc col dy * k; if (nr 0 || nr N || nc 0 || nc N) { break; } if (board[nr][nc] ! player) { break; } cnt; } for (int k 1; k 5; k) { int nr row - dx * k; int nc col - dy * k; if (nr 0 || nr N || nc 0 || nc N) { break; } if (board[nr][nc] ! player) { break; } cnt; } return cnt; } int check_win(int board[N][N], int row, int col, int player) { for (int d 0; d 4; d) { if (count_line(board, row, col, dirs[d][0], dirs[d][1], player) 5) { return 1; } } return 0; }这里count_line里的双向计数是胜负判断的核心。以当前棋子作为分界点向两边各最多数4格正好凑成五连的判定窗口。每一次遇到边界、空格或者对方棋子就立即停止因为连续性被破坏了后面再出现同色棋子也不算数。3.3 藏在“5”里的边界语义很多教程代码用 5判断胜利这是一个值得留意的细节。五子棋规则很简单同色棋子连成五子即胜。如果一盘棋里某一方形成了六连甚至七连按规则当然也是包含五连的。用 5判断逻辑上更安全也避免出现“明明连成一长条却因为长度不是整五而不判赢”的尴尬情况。另一个容易忽视的坑是计数重复。有人在判断四个方向时不是从当前点向两端数而是把正方向和反方向各扫一遍后直接把两边都应用到整个棋盘比如用嵌套循环把所有同色连续格子都数一遍再判断整个线段长度。这种写法在连续线段超过5时容易出问题因为一个线段会被两个不同落子点各统计一次导致“明明只有四连却被判成五连”。使用方向数组并固定以当前落子点为原点就能完全避开这类重复计数。还有一个关于边界的细节在count_line中每次访问board[nr][nc]之前都必须检查nr、nc是否在0到14之间。少了这一步角落位置的落子会直接读取棋盘外的内存行为无法预测。这既是为了稳定性也是为了让胜负判断在任何位置都可以安心运行。到这里数据模型、落子校验和胜负判定这三个核心模块已经齐了。但一个能玩起来的游戏还需要一个主循环把这几块串成一个完整闭环。4. 主循环与对局状态从“能跑”到“能玩”的关键细节4.1 主循环里的状态流落子、判定、交换、继续主循环是整个小游戏的骨架我的写法很简单不会引入复杂的结构int player 1; int moves 0; while (1) { print_board(board); printf(当前玩家 %d 落子请输入行列例如 8 8\n, player); char line[64]; if (fgets(line, sizeof(line), stdin) NULL) { continue; } for (char *p line; *p; p) { if (*p , || *p ) *p ; } int row, col; if (sscanf(line, %d %d, row, col) ! 2) { printf(输入格式不对请输入两个数字。\n); continue; } row--; col--; if (!drop(board, row, col, player)) { printf(这个位置不能落子请重新输入。\n); continue; } moves; if (check_win(board, row, col, player)) { print_board(board); printf(玩家 %d 获胜\n, player); break; } if (moves N * N) { print_board(board); printf(棋盘已满和棋。\n); break; } player (player 1) ? 2 : 1; }这段循环看起来短它把前面所有模块都串了起来。落子失败时用continue不切换玩家落子成功后才递增moves每次落子后先判胜负再判和棋最后才轮到下一位玩家。这三个判断的顺序是有讲究的只有当前棋子可能改变胜负状态所以每步都检查棋盘满的情况下最后一步落完肯定不会立刻赢因为如果这一步形成五连游戏已经结束了。4.2 和棋条件不能只判断“棋盘满”还要看落子数很多同学写完游戏后发现游戏进行到最后棋盘满了程序还在等输入唯一的出路是强制关闭窗口。原因是没处理和棋。判断和棋最直接的方式是维护一个moves变量。每次成功落子时加一当moves等于N * N时意味着225个位置全部被填满。放在这里判断有一个好处它不会误判因为在最后一次成功落子且没有形成五连的情况下游戏确实没有赢家。当然你也可以每次都扫描棋盘判断是否还有空格但维护一个整型计数变量显然更轻量。这个变量对后面加AI也有用AI可以根据剩余落子数估算局面。4.3 清屏与“再来一局”要不要做清屏是一个容易纠结的点。system(clear)在Linux终端有效system(cls)在Windows下有效但这两个命令在对方环境下会打印一行不认识的字符。考虑到五子棋小游戏通常是控制台程序棋盘15行加列标题信息量并不大我建议不做清屏让上一盘棋的棋盘留在滚动缓冲区里玩家反而能回溯对局过程。做完一局后如果想再玩可以再包一层外层循环但我不太建议把“再来一局”写进第一版。先保证一局游戏完整稳定比支持无限重开更重要。5. 要不要加AI一个“减法思路”比硬上复杂算法靠谱得多5.1 最弱的AI也有价值先保证“它真的会下棋”很多课程设计的要求是双人对战但加AI是一个常见的加分方向。我的建议是先把纯双人版本测试稳定再加一个简单的电脑对手。最简单的AI只需要两步如果棋盘为空落子在正中央否则在自己已有棋子的旁边找一个空位落子。这种AI严格说没有任何智能但它至少能让单机玩家过一把瘾而且代码量只有十来行。它最大的价值在于验证主循环是否能支持“电脑自动合法落子”这件事为后续改进打基础。5.2 用“进攻分防守分”让AI真正会堵棋比随机落子强一个档次也仍然不复杂的思路是对每个空位做一个打分。打分的核心是模拟思考对每个空格(r, c)先假设自己把棋子下在这里然后在四个方向上统计能形成多少连续的己方棋子根据连续数得到一个进攻分。再假设对手把棋子下在这里统计对手棋子连续数得到一个防守分。最终这个位置的总分是两者加权相加。比如连续棋子数进攻得分防守得分已有1子连1020已有2子连100200已有3子连10002000已有4子连1000020000用表格的目的不是死记这些数值而是体现一种原则防守的权重通常是进攻的1.5到2倍因为五子棋里“堵住对手的活三”往往比“自己去冲一个不是绝对先手的三连”更紧迫。实际实现时可以临时把棋子放进棋盘调用类似count_line的函数统计完成统计后立刻把该位置恢复成空。这一步千万不能漏不然AI走完一步后棋盘里会出现两个虚拟落子后面的所有判断全部作废。选最高分的空位落子这个AI已经具备了基本的攻防意识。它不会主动设计复杂的双三双四但面对人类随手下的棋至少能做到“看到四连就堵、看到三连也堵、自己有三连就延伸”。作为课程设计的一个加分项这个强度已经足够拿得出手。5.3 为什么我不建议第一版就上极大极小算法很多人一提到AI就想到极大极小搜索、Alpha-Beta剪枝、置换表。这些算法当然厉害但它们对代码量和调试成本的要求也高得多。在C语言小游戏这个体量下一个完整的启发式打分AI通常不超过50行而一个三层搜索的AI可能要写两百行以上还要处理评估函数的棋盘快照拷贝、递归深度控制、搜索时间限制。我的建议是先用启发式打分如果项目时间充裕再在它上面加一层极小步数的前瞻搜索比如只考虑双方各自下一步的位置。这样做出来的代码既思路清晰也能抗住课程设计答辩时老师对“你这里为什么这样选”的追问。6. 调试与维护心得几次实测崩溃背后的共同原因6.1 一张表看常见的五子棋程序崩溃场景我在帮人调试时把这些年常见的崩溃场景整理了一下很多问题具有高度一致性。症状根因修复方式输入1 1后棋子落在第二行第二列用户坐标未转换成数组下标解析后统一执行row--; col--;输入15 15后闪退直接访问了board[15][15]越界落子函数里先检查行列边界玩家已经赢了程序还在等下一次输入判定胜利后没有跳出主循环胜利分支里用break结束循环每隔一步就少一次输入用scanf后缓冲区残留换行换用fgets加sscanf读取棋盘显示乱码终端不支持UTF-8的棋子符号改用X、O、.等ASCII字符落子到边缘时突然判定错误胜负判断没有检查越界count_line里每次取格子前判断边界这张表基本覆盖了我实际见到的大多数问题。你会发现没有哪一条是“算法太难”导致的全是细节规范性不足。6.2 用固定棋谱验证胜负判断不要靠随机地点鼠标写销胜负判断后不要急着直接玩完整游戏先固定摆几个棋谱测试。比如在数组里手动放四个连续的1两端留空然后调用check_win确认四种方向都能正确返回1再放三个连续1一个空格一个1确认不会误判成胜利。这样做的好处是你能把棋盘状态精确控制在“冲四”“活三”“普通三连”几种关键形态下快速定位是计数逻辑的问题还是方向向量写错了。靠随机操作去试错效率低且容易漏掉边界场景。6.3 先保证双人模式稳跑再考虑加AI最后一条建议可能听起来很像废话但它的作用非常大加入电脑AI之前先把双人模式完整跑二三十局。别觉得二三十局很多实际上一个稳定的双人五子棋每局只需要几分钟。二三十局足够暴露坐标转换、输入缓存、胜负判定里的绝大多数问题。如果双人模式本身还有“偶尔多下一步”的毛病这时候加AI你根本分不清输棋是因为AI太弱还是棋盘状态本身就错了。把基础版本做稳AI作为增量功能往上搭排查问题的范围会小很多。我在实际调试中还养成了一个习惯把棋盘打印函数作为一个独立的“观察窗口”每走一步都在终端里重新输出而不是依赖什么高级图形界面。控制台程序的调试手段本来就有限清晰、可复盘的输出比什么都重要。这也是为什么我在整篇里反复强调数据模型和打印函数不复杂但它们决定了你之后所有调试工作的效率。
阅读完成 · 觉得有帮助?
咨询建站