ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C语言五子棋实战:从二维数组到AI评估函数的设计与实现

C语言五子棋实战:从二维数组到AI评估函数的设计与实现

简介:这是一份用C语言实现的五子棋游戏项目压缩包,覆盖双人对战、单人对战AI与悔棋功能,适合C语言初学者、高校学生作为课程设计或综合实践参考。资源包内含33个文件,整体约52.65MB,主要结构包含源文件(.c/.cpp)、Visual Studio工程文件(.sln/.vcxproj)、调试辅助文件(.pdb/.ilk)、运行截图(.png)及可直接运行的.exe程序,清晰对应从源码编辑、编译链接到运行调试的完整开发链路。在功能实现上,项目通过二维数组管理棋盘状态,实现了落子合法性校验、五子连珠胜负判定、人机对弈决策及基于历史步骤回退的悔棋机制,同时兼顾了输入错误处理和游戏结束条件检测,代码结构按棋盘逻辑、用户交互等模块拆分,便于阅读与二次开发。随包附带的多张运行效果截图,可帮助快速核对界面表现与功能完成度。目前已有2643人浏览学习,对需要独立完成C语言游戏开发或积累项目经验的学习者,是一份涵盖核心知识点且可直接运行的研究样例。

1. C语言五子棋项目:一门课设装下指针、数组与状态管理的全部考点

如果你正在找期末课设、复习 C 语言或者给自己的项目经历攒点东西,C语言五子棋(双人对战、单人对战、悔棋)是最合适的综合练习:它只有几百行代码,却把二维数组、函数模块化、输入输出控制、状态机、简单的 AI 评估和悔棋的数据结构全部串了一遍。更难得的是,五子棋的规则足够简单,你不需要理解复杂的算法就能上手改,但想把它做得手感顺畅、没有 bug,又必须把逻辑边界想清楚——这正是考试题和真实工程之间那条最值得跨过的线。

我见过很多人交上来的五子棋都是“能跑但不敢动”:鼠标点快点就崩,悔棋悔到一半棋盘错乱,电脑乱下毫无还手之力。这篇文章不讲虚的,直接把棋盘建模、胜负判定、悔棋实现、单机 AI 评估函数和调试避坑一步步写给你。你照着敲下来,得到的不只是一个能交差的 zip 包,而是一套你能跟别人讲清楚的设计思路。

2. 棋盘建模与胜负判定:把 15×15 棋盘变成可计算的二维数组

2.1 棋盘的数据结构:二维数组与 zero 值约定

五子棋最核心的数据就是一个棋盘。常见做法是用char board[15][15]表示 15×15 的格子,取值约定为:0表示空,1表示黑子,2表示白子。用char而不是int是因为它在内存里只占 1 字节,棋盘的全部状态不过 225 字节,对栈的压力几乎为零,也方便后续做存档时直接写入二进制文件。

#define BOARD_SIZE 15 char board[BOARD_SIZE][BOARD_SIZE]; void init_board(void) { for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) board[r][c] = 0; }

这段代码的逻辑很简单:双层循环把每个格子清零。你或许觉得这不值一提,但它决定了后面所有函数的约定——只要某个函数忘掉初始化就直接读写棋盘,你拿到的是栈上的随机值,轻则显示乱码,重则胜负判定直接误判。所以我会在main()一进来就调用init_board(),宁可多做一次,也不给“黑匣子”留机会。

参数方面,BOARD_SIZE用宏定义而不是字面量 15,是为了后续如果改成 19 路围棋盘,你只需要改这一行。不要小看这个习惯,我把课设交上去后,老师随口问“改成 19×19 要动哪些代码”,我能直接回答“只动宏定义”,这就是宏定义的价值。

2.2 落子与合法性校验:边界、占用与坐标映射

玩家输入坐标后,程序必须先校验:坐标是否在 0~14 范围内、该位置是否已经被占。很多人把这个校验写在主循环里,我建议单独拆成函数,因为单人对战 AI 落子前也要走同样的校验,双人模式里悔棋恢复局面时也要判断当前位置是否为空。

int is_valid_move(int row, int col) { if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return 0; // 越界 if (board[row][col] != 0) return 0; // 已被占用 return 1; } void place_stone(int row, int col, int player) { if (!is_valid_move(row, col)) { printf("非法落子:%d,%d\n", row, col); return; } board[row][col] = (char)player; }

place_stone里的player参数直接存 1 或 2,黑先白后。这里面有个很容易忽略的问题:玩家输入的坐标是“第几行第几列”,但终端里人眼习惯的是从 1 开始计数,所以读到输入后要执行row--; col--;再做校验。我见过太多翻车案例是把这步忘了,导致落子位置总是偏移一格。你的坐标映射必须是单一约定,要么全程 0 基、要么全程 1 基,只在输入边界转换一次。

2.3 五子连线判定:四方向扫描的正确写法

胜负判定是五子棋最容易写出 bug 的地方。很多人用四个独立函数分别判断横向、纵向、两条斜线,代码复制粘贴四遍,改了一个方向忘了另外三个。我推荐用一个方向偏移数组统一处理。

int check_win(int row, int col, int player) { int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (int d = 0; d < 4; d++) { int dr = dirs[d][0], dc = dirs[d][1]; int count = 1; // 正方向延伸 for (int i = 1; i < 5; i++) { int nr = row + dr * i, nc = col + dc * i; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] != player) break; count++; } // 反方向延伸 for (int i = 1; i < 5; i++) { int nr = row - dr * i, nc = col - dc * i; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] != player) break; count++; } if (count >= 5) return 1; } return 0; }

这个函数只检查以当前落子为中心、四组方向上的连续同色子数量。四个方向数组分别是水平、垂直、主对角线、副对角线,每组循环都从 1 开始延伸,分别往正反两个方向数,最后加起来count >= 5就判胜。

参数说明里最值得留意的是方向数组dirs:它的第三项{1,-1}表示副对角线方向,行递增、列递减,很多人写到这里会漏掉或者写反。另一个参数细节是循环上限用i < 5而不是i < BOARD_SIZE,因为五子棋只需要数到五,多了是浪费。你要是想支持“六连也算赢”,把上限改大即可。

3. 双人对战与悔棋:把每一步按回去的栈设计

3.1 双人对战主循环:交替落子与局面刷新

双人对战的本质是一个状态机:黑方落子 → 判断胜负 → 白方落子 → 判断胜负 → 循环。这里最关键的变量是当前玩家current_player,它每轮都在 1 和 2 之间切换。我用一个int turn = 1;表示黑方先行,落子成功后执行turn = (turn == 1) ? 2 : 1;。

int current_player = 1; int game_over = 0; while (!game_over) { int row, col; printf("玩家%d 请输入落子坐标(行 列):", current_player); scanf("%d %d", &row, &col); row--; col--; if (!is_valid_move(row, col)) { printf("非法位置,请重新输入\n"); continue; } place_stone(row, col, current_player); print_board(); if (check_win(row, col, current_player)) { printf("玩家%d 获胜!\n", current_player); game_over = 1; break; } // 记录这一步,供悔棋使用 push_history(row, col, current_player); current_player = (current_player == 1) ? 2 : 1; }

这段代码的要点在于continue:输入非法时不切换玩家,而是直接跳回循环开头重新读输入,否则会出现“黑方输错一步,白方被跳过”的状态错乱。另一个隐蔽点是push_history要放在check_win之后,因为游戏已经结束时那一步不该进悔棋栈,否则赢了之后按悔棋反而回到未赢的局面,逻辑上自相矛盾。

scanf("%d %d", &row, &col)这里有个输入手感问题:如果玩家多输入了一个空格,scanf 能自动跳过;但如果玩家输入了字母,scanf 返回 0,而row和col保留旧值,主循环会死循环。这里最常见的处理是在读取后检查返回值是否为 2,不是则清空输入缓冲区重新读。这个问题我在后面避坑章节专门讲。

3.2 悔棋的等价实现:用结构体数组模拟栈回退

悔棋的本质是撤销最近一步。理论上有两种方案:一是存整盘棋局的快照,悔棋时整体还原;二是只存每一步的位置和玩家,悔棋时把对应格子清零。前者浪费内存,后者才是主流。

typedef struct { int row; int col; int player; } Move; Move history[225]; // 最多 225 步 int top = -1; // 栈顶指针 void push_history(int row, int col, int player) { if (top >= 224) { printf("历史栈已满,无法记录\n"); return; } top++; history[top].row = row; history[top].col = col; history[top].player = player; } int undo_move(void) { if (top < 0) { printf("没有可悔的棋\n"); return 0; } Move m = history[top]; board[m.row][m.col] = 0; top--; return 1; }

注意我选的是结构体数组当栈用,而不是链式栈。原因很简单:五子棋对局终局最多 225 步,数组可以静态指定大小,不需要动malloc/free,避免内存泄漏这道坎。栈顶指针top从 -1 开始,表示空栈;每记录一步 top 先加 1 再写入,悔棋时先取出再减 1。

参数history[225]这个上限不是拍脑袋写的:15×15 棋盘全部下满正是 225 手。你可以在 push 函数里的边界判断看到,我用了top >= 224而非>= 225,因为 top 从 0 开始计数,第 225 个元素的下标是 224。这个“差一”错误(off-by-one)是悔棋功能最常见的崩溃源头,后文还会展开。

3.3 悔棋深度限制:防止悔棋悔成空棋盘

如果没有任何限制,玩家可以一路悔棋把棋盘清空。从教学演示的角度这没毛病,但从体验上,双人对战里应该限制“最多悔 5 步”或“每人每局悔 3 步”。实现方式可以在undo_move里加一个计数器。

int undo_limit = 5; int undo_count = 0; int undo_move(void) { if (top < 0) { printf("没有可悔的棋\n"); return 0; } if (undo_count >= undo_limit) { printf("已达本局悔棋上限(%d 步)\n", undo_limit); return 0; } Move m = history[top]; board[m.row][m.col] = 0; top--; undo_count++; return 1; }

这里我单独拆出undo_count而不是复用top,因为top会随正常落子增长,而悔棋次数是独立限制。你甚至可以做更细的策略:黑方悔 3 次、白方悔 3 次,各自独立计数。但初学者我建议先用全局上限,逻辑更直观。

需要注意的一点是:悔棋后玩家重新落子,悔棋计数要不要重置?常见设计是不重置,因为悔棋次数限制的是“本局总撤销次数”,防止无限悔棋刷胜率。你要是想做成游戏大厅那种“每人每步悔一次”,就得在换手时重置undo_count,这个决策没有标准答案,但要在需求文档里写清楚,否则测试的人会当成 bug 提给你。

4. 单人对战 AI:用评估函数让电脑做出像样的回应

4.1 评估函数:给每个空位置计算攻防分数

单人模式的核心是让电脑落子。最简单也最实用的方法是评估函数:遍历棋盘每个空位,给它打分,分数由“这个位置对电脑自己有多有利”和“这个位置对玩家有多危险”两部分组成。常见公式是score = attack_score + defense_score,其中防守分量通常乘以一个系数(比如 1.2),因为五子棋里后手防住对方的必胜局比单纯进攻更重要。

int evaluate_position(int row, int col, int ai_player) { int human_player = (ai_player == 1) ? 2 : 1; int score = 0; int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (int d = 0; d < 4; d++) { score += count_line_score(row, col, ai_player, dirs[d][0], dirs[d][1]); score += count_line_score(row, col, human_player, dirs[d][0], dirs[d][1]) * 12 / 10; } return score; } int count_line_score(int row, int col, int player, int dr, int dc) { int count = 1; for (int i = 1; i <= 4; i++) { int nr = row + dr * i, nc = col + dc * i; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] == player) count++; else if (board[nr][nc] == 0) break; else break; } for (int i = 1; i <= 4; i++) { int nr = row - dr * i, nc = col - dc * i; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] == player) count++; else break; } return count; }

这段代码把“评估”拆成两层:外层evaluate_position负责四个方向累加,内层count_line_score负责数某个方向上有多少连子。注意我把human_player的分数乘以了 12 再除以 10,也就是防守权重比进攻高 20%。

这个 1.2 系数不是玄学,它的含义是:当某个位置既能帮 AI 形成四连、又能挡住玩家的四连时,优先选择防守。因为进攻被挡住只是少一次机会,但防守失败意味着对方立刻五连获胜。你可以把它改成 1.0 或 2.0 试试手感,AI 会从激进变成保守,这就是参数调优的一次直观体验。

4.2 落子选择策略:遍历打分与最高分优先

有了评估函数,AI 的下法就变成一次全盘扫描:

void ai_move(int ai_player) { int best_score = -1; int best_row = -1, best_col = -1; for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (board[r][c] != 0) continue; int s = evaluate_position(r, c, ai_player); if (s > best_score) { best_score = s; best_row = r; best_col = c; } } } if (best_row == -1) { printf("AI 找不到可落子的位置\n"); return; } place_stone(best_row, best_col, ai_player); }

best_score初始化为 -1 很关键:因为评估分数永远是非负的,任何合法空位都能让分数大于 -1,所以第一个遇到的空位会成为基准。如果棋盘已满,best_row保持 -1,函数直接退出,避免落一个非法子把程序弄崩。

这里的扫描顺序是从 (0,0) 开头逐行往下的,如果多个位置分数相同,AI 永远选择靠近左上角的那个。表现上会让人感觉“电脑有偏好”,其实只是扫描顺序的产物。如果你想让 AI 在同等分数下随机选一个,可以把>改成>=配合随机数,但这会引入rand()和srand(),我建议初学者先把确定性逻辑跑通,再加随机性。

4.3 先手后手与交互控制

单人对战中的 AI 可能是黑方也可能是白方,这意味着主循环里必须判断当前落子的是人还是 AI。常见做法是加一个int mode;变量,1表示双人,2表示人机。在人机模式中,每次轮到 AI 时调用ai_move(),然后自动进入人类回合。这里有个很容易忽略的细节:AI 落子后同样要调用check_win(),否则会出现“AI 下出五连却没有任何提示”的尴尬局面。

if (mode == 2 && current_player == ai_player) { ai_move(ai_player); if (check_win(last_row, last_col, current_player)) { printf("电脑获胜!\n"); game_over = 1; } push_history(last_row, last_col, current_player); current_player = (current_player == 1) ? 2 : 1; } else { // 人类输入流程 }

这段伪代码里的last_row和last_col必须在落子函数里同步更新。我的做法是让place_stone内部在成功落子后更新这两个全局变量,这样人机双方共享同一套“记录最后一步”的逻辑。你可能会觉得为了一个 AI 功能改全局变量不值得,但等你写完会发现,last_*在悔棋和胜负判定里也都要用,还不如一开始就建好。

AI 的搜索深度这里做的是 1 层,也就是只评估“当前一步局面的分数”。升级到 2 层意味着要递归模拟自己和对手的后续落子,复杂度从 O(225) 变成 O(225²),代码量和调试难度都上一个台阶。如果你只交课设,1 层评估已经能让电脑完成基本的堵三、堵四动作。先跑通 1 层,你才有资格谈“minimax 剪枝”。

5. 常见踩坑:五子棋项目里最容易翻车的五个位置

5.1 scanf 输入字母导致的死循环

现象:玩家在落子坐标时不小心输入了字母a,程序不报错也不退出,像死机一样卡在大段输出里。

原因:scanf("%d")遇到非数字字符时不会消费这个字符,它会留在缓冲区里,下一次 scanf 又读到同样的字符,如此往复,循环空转。

解决:在每次scanf后检查返回值,如果返回不是 2,就用while(getchar() != '\n');清掉这一行的残留字符,然后让玩家重新输入。

int row, col; while (1) { printf("请输入坐标:"); if (scanf("%d %d", &row, &col) == 2) break; while (getchar() != '\n'); // 清空缓冲区残留 printf("输入无效,请重新输入数字\n"); } row--; col--;

这个while(getchar() != '\n');是关键后半段:它会把输入行里所有字符读完,直到遇见换行。没有这一行,即使你返回了错误提示,下次scanf依然会读到旧的a,继续死循环。这是 C 语言初学者最容易翻车的点,所有需要交互读数的程序都通用的坑。

5.2 胜负判定数组越界:方向延伸时没做边界检测

现象:下到棋盘边缘时(比如第 0 行的位置),程序偶尔崩溃,或者出现完全不合理的“五连”判定。

原因:check_win里的方向循环没有检查nr/nc是否越界就访问了board[nr][nc]。在 C 语言里,越界访问读取的是栈上的脏数据,如果恰好读到和当前玩家相同的数值,就会误判为连子。

解决:在第 2.3 节给出的代码里我已经做了边界判断:每次访问前先检查nr和nc是否处于 0~14 范围内,不在范围内直接break停止延伸。这是“先判断后访问”的典型例子,C 语言里数组越界是不会给你报错的,你得靠自己把边界焊死。

5.3 悔棋栈顶指针不同步:悔一步后棋盘乱了

现象:玩家下 3 步棋后按悔棋,棋盘上只消失了第 3 步,然后再下第 4 步时,第 2 步消失或者位置错乱。

原因:undo_move里top--后,下一步落子时push_history直接用history[++top]覆盖,逻辑上没问题;但如果我在place_stone里把push_history写在了check_win之前,那赢了之后栈里多了一个不该有的记录,悔棋一次会同时撤掉胜利判定和棋步。

解决:坚持“落子 → 判胜 → 记录 → 换手”的顺序,让push_history只记录有效对局中的步数。另外,每次悔棋后打印一下top的当前值,对照棋盘总步数就能快速定位“多记了 null 还是少记了一步”。调试栈结构时用打印日志,比盯着数组看有效得多。

5.4 单人对战里 AI 忽略了自己的胜利

现象:电脑其实已经四连了,但下一手 AI 不去走必赢的那步,反而去堵玩家的棋。

原因:评估函数把进攻和防守分开计分,但防守权重乘了 1.2,导致玩家一个“活三”的防守分,压过了 AI 自己的“冲四”进攻分。评估系统没有优先处理“先赢”这个最高优先级。

解决:在evaluate_position最前面加一个硬逻辑:如果当前空位落子后自身能形成五连,直接返回一个极大值,比如100000。这样 AI 看到必胜手会立刻选择,而不是被防守分干扰。这种“特殊情形优先于通用打分”的设计在棋类 AI 里叫“杀招优先”,是评估函数从玩具到能用的分水岭。

5.5 双人模式悔棋后同一位置出现双重落子

现象:黑方落子 → 白方落子 → 黑方悔棋撤掉自己的子 → 白方继续时发现棋盘上出现了两个白子。

原因:悔棋后主循环没有把current_player切回被撤销的那一方。假设当前轮到白方,白方悔了自己的上一步,current_player还是白方,下一次循环白方继续落子,落下的新子会覆盖到原有白子的位置,或者因为占用检查而被拒绝。

解决:悔棋成功时,把current_player改为被撤销步的player字段值。例如撤销的是黑方步,就令current_player = 1。这个规则在双人模式里极其容易漏,因为人眼看到的是棋盘,但程序看到的是状态变量。我的习惯是写一个辅助函数do_undo(),内部同时完成栈回退和换手,这样主循环不用关心撤销的细节。

6. 把 zip 包里交上去的东西做成名副其实:测试、边界与交付习惯

最后的收尾不在于增加功能,而在于让整个项目可以被验证。我见过太多五子棋代码逻辑是对的,但交上去评审时一问三不知,或者演示时从终端复制粘贴整个棋盘看不出状态。你至少要做三件事:多文件拆分、自动测试、以及输入边界的记录。

多文件拆分是让评委觉得“这人真的会 C 语言”的最快方式。我一般会拆成main.c(菜单与主循环)、board.c(棋盘操作与判定)、ai.c(评估与 AI 落子)、history.c(悔棋栈),每个文件配一个.h头文件声明接口。编译命令用gcc main.c board.c ai.c history.c -o gomoku -std=c99,不要用单个gomoku.c融成一坨。你的核心函数都定义在 board.c 里,main.c 只负责调用,这样自己查错也方便。

自动测试这一点容易被忽略。你可以在main.c里加入一个隐藏参数--test,启动时快速模拟几局棋:让 AI 和 AI 自己对弈,或者直接往棋盘里塞一组固定坐标,然后打印check_win的结果。这样改完评估函数后跑一遍能立刻发现“AI 五连了但没报赢”这类回归。手工一局局去下,既慢又漏。

最后一件事,把“悔棋上限”“是否允许连续悔棋”“AI 先后手”这几个参数做成常量放在config.h里,而不是散落在代码中。你明天回看自己的代码,能一行改完人性化配置,比在几百行里找5这个数字惬意得多。我自己吃过亏,悔棋上限直接写死成5,后来想改成每步可悔一次,翻代码翻了二十分钟。从那以后,凡是“可以给别人看”的项目,我都把这类参数集中管理。

有人说 C 语言写五子棋太底层、不够炫,但他很少意识到,正因为它没有框架和库帮你兜底,每一次数组越界、每一次输入残留、每一次栈指针错位都是在逼你理解内存和流程控制。把这套东西吃透,你后面去看嵌入式、看网络编程里的状态机,会发现都是熟人。希望这份实现思路能帮到你,少走几步我当时走过的弯路。

本文还有配套的精品资源,点击获取

返回列表