大二上学期把C语言语法啃完之后,我陷入了一个很尴尬的状态:指针、结构体、文件读写这些知识点零零散散都见过,但真要独立写一个能跑的项目,脑子里一片空白。后来逼自己从零写了一个三子棋小游戏,也就是标题里这个"呆头呆脑的电脑"——电脑只会随机落子,蠢得让人哭笑不得,可就是这么一个简陋的程序,把我对数组、函数、程序结构、输入输出、随机数的理解全部串起来了。这篇博文就把完整思路和代码拿出来拆一拆,给同样处于"学完语法但不会做东西"阶段的同学做个参考。
1. 为什么选三子棋当C语言练手项目:先聊点心得体会
1.1 项目规模刚刚好,不会劝退
我第一次写三子棋之前,其实动过写贪吃蛇或者五子棋的念头,后来冷静下来想了想,还是三子棋最合适。贪吃蛇牵扯到控制台光标定位、按键监听、动态数据存储,一上来就搞这些,很容易被细节淹没;五子棋呢,棋盘变成15×15,胜负判定要处理四个方向,逻辑量翻了好几倍。三子棋的棋盘是3×3,总共9个格子,所有状态用一张二维数组就能装下,胜负判定只有行、列、两条对角线,总共8种情况,刚好卡在"有点挑战但够得着"的位置上。
这个规模的好处是:你不需要花大量时间在环境配置和框架搭建上,打开一个源文件从头写到尾,两三百行代码就能跑起来。写完之后你会获得一个完整可玩的程序,这种正反馈对初学者非常关键。我第一次跑起来的时候,看到电脑跟我一来一回地下子,成就感比做十道课后题都强。
1.2 这个项目覆盖了C语言的核心知识点
我把三子棋拆开看,它几乎把C语言基础语法里最常用的东西都过了一遍:
| 知识点 | 在项目中的体现 |
|---|---|
| 数组 | 用char board[3][3]存储整个棋盘状态 |
| 循环 | 初始化棋盘、打印棋盘、主循环反复下棋 |
| 分支判断 | 输入合法性校验、胜负判定、平局判定 |
| 函数封装 | 初始化、打印、玩家落子、电脑落子、判断胜负各自独立 |
| 随机数 | 电脑用rand()决定落子位置 |
| 输入输出 | printf打印棋盘、scanf读入玩家坐标 |
| 宏定义 | 用#define ROW 3代替魔数,代码更清晰 |
这正好回应了很多人的困惑:"我学完了C语言基础,但不知道能做什么"。三子棋不是生产级项目,但作为把语法翻译成代码的桥梁,性价比极高。做完之后你再去看指针、结构体,至少不会觉得它们悬浮在抽象层面,因为你已经知道怎么用数据结构和函数去组织一个真实程序了。
1.3 "呆头呆脑"其实是刻意为之
标题里的"呆头呆脑"说的是电脑AI,它没有任何策略,纯粹随机选择一个空格下子。这不是因为我写不出聪明的AI,而是刻意先做一个最简版本,把游戏框架跑通。随机版AI有一个很直观的教学价值:你一眼就能看出它为什么蠢——因为代码里没有任何"思考"逻辑,落子之前不检查有没有两连的棋子,不堵你,也不抢中间位置。看懂了它为什么蠢,后面改进AI的策略就有了明确方向。
而且随机AI省去了大量复杂判断,代码的主体都集中在棋盘操作和游戏流程上。对于第一次写完整项目的人来说,先把骨架立起来,再往骨架上填肉,这个顺序非常重要。
2. 棋盘建模与函数拆分:代码框架的搭建思路
2.1 为什么用二维字符数组存棋盘
棋盘的第一个问题是"用什么数据结构描述它"。三子棋的3×3格子天然对应二维数组,所以这一步是很自然的。选择char而不是int,是因为棋盘的每个格子里只有三种状态:空、玩家棋子(X)、电脑棋子(O),用字符存储直观,打印时直接printf("%c", board[i][j]),不需要做数字到字符的转换。如果用int存0、1、2。当然也能做,但打印时就得多写一层转换逻辑,纯属给自己找麻烦。
初始化的逻辑也很简单:
#define ROW 3 #define COL 3 char board[ROW][COL]; void initBoard() { for (int i = 0; i < ROW; i++) { for (int j = 0; j < COL; j++) { board[i][j] = ' '; } } }这里我把棋盘定义成了全局变量。很多教科书写到全局变量就提醒你"慎用",但在这个小项目里,棋盘是唯一的共享状态,main函数和各个子函数都要直接操作它。如果非要用传参方式,每个函数都要把二维数组传进来,签名会变成void displayBoard(char board[ROW][COL]),对初学者来说反而增加了理解成本。我的原则是:小型单文件项目用全局变量没问题,只要你能说清楚为什么这样写。
2.2 打印棋盘:格式是最容易被忽略的体验
棋盘的打印看着不起眼,实际上决定了程序好不好用。第一次写的时候我用了一堆printf硬拼,结果行列对不齐,棋盘歪歪扭扭的。后来改成每行先打印边框再打印棋子,棋盘就整齐了。
void displayBoard() { printf("\n 1 2 3\n"); // 列号提示,方便玩家对标输入 printf("1 %c | %c | %c \n", board[0][0], board[0][1], board[0][2]); printf(" ---+---+---\n"); printf("2 %c | %c | %c \n", board[1][0], board[1][1], board[1][2]); printf(" ---+---+---\n"); printf("3 %c | %c | %c \n", board[2][0], board[2][1], board[2][2]); printf("\n"); }这种写法的好处是打印逻辑一目了然,坏处是如果棋盘尺寸改成4×4或者5×5,这段代码就得重写。更进阶的做法是用循环去遍历行列,让显示函数自适应棋盘大小。我当时是先写死了版本,再改成循环版,两次对比下来对循环和数组下标的理解加深了不少。如果真的想把基础打牢,建议写完死板版之后,主动重构一遍用循环打印的版本。
2.3 函数拆分的粒度:一个函数只干一件事
我最初犯的典型错误是把所有逻辑全堆在main里,玩家输入、校验、落子、判断胜负全在一个循环里,代码极其臃肿,出问题根本没法查。后来按功能拆成几个函数,整个程序的逻辑瞬间清楚了很多。
| 函数名 | 职责 |
|---|---|
initBoard() | 初始化棋盘为全部空格 |
displayBoard() | 打印当前棋盘 |
playerMove() | 玩家输入坐标并落子 |
computerMove() | 电脑随机选择一个空位落子 |
checkWin() | 判断当前是否有胜负或平局 |
main() | 控制整体游戏流程 |
这种拆分思路在任何语言里都通用:把"做什么"和"怎么做"分开,每个函数只关注自己的事。比如playerMove()只管输入和落子,它不需要关心胜负;checkWin()只做判断,不打印多余内容。这样写的好处是调试的时候定位极快。有一次我发现自己明明走了两步,棋盘上只显示一步,马上就能怀疑到playerMove()的循环条件,而不是在几百行代码里翻来翻去。
3. 玩家与电脑轮流落子:输入处理和随机数的门道
3.1 玩家输入的合法性校验
玩家落子不能瞎写。坐标越界不行,已经有棋子的位置也不行,输入非数字更不行。我第一版只检查了越界和重复,结果玩家不小心输入一个字母,程序直接进入死循环,只能强制关闭终端——这就是scanf的老毛病。
void playerMove() { int x, y; int row, col; while (1) { printf("请输入落子坐标(行 列,范围1~3): "); if (scanf("%d %d", &x, &y) != 2) { // scanf没有正确读取两个整数,说明输入含非数字字符 // 把缓冲区里残留的字符读掉,防止死循环 while (getchar() != '\n'); printf("输入格式有误,请重新输入\n"); continue; } row = x - 1; col = y - 1; if (row < 0 || row >= ROW || col < 0 || col >= COL) { printf("坐标必须在1~3之间,请重新输入\n"); continue; } if (board[row][col] != ' ') { printf("这个位置已经有棋子了,请重新输入\n"); continue; } board[row][col] = 'X'; break; } }这里有两个细节新手特别容易踩。第一个是人机交互的坐标习惯:玩家习惯从1开始数,而C数组下标从0开始,所以读取之后要各减1再落到数组上。第二个是scanf失败的清理问题:如果用户输入了"abc",scanf会返回0而不是2,此时三个字符还留在输入缓冲区里,下一次scanf又读到同一个字母,无限循环。解决方式就是while (getchar() != '\n');,把当前行剩余的字符清空。
3.2 为什么所有代码都跑通了,电脑落子却永远一模一样
电脑随机落子,用的是rand()函数。但如果你光调rand()不设置随机种子,会出现一个很诡异的现象:每次启动程序,电脑的落子顺序完全相同。一开始我还以为是我的逻辑写错了,后来才意识到rand()生成的是伪随机序列,默认种子是固定的,所以每次运行拿到的都是同一串数字。
解决办法是在main()开头加一行:
#include <stdlib.h> #include <time.h> srand(time(NULL));time(NULL)会返回当前时间的整数表示,用当前时间做种子,每次启动程序时序列就会不同。注意time(NULL)包含在<time.h>头文件里,rand()和srand()包含在<stdlib.h>里,这两个头文件很容易漏。如果只是第一次运行发现"随机数不随机",多半就是种子没设置。
3.3 电脑随机落子:收集空位比盲目重试更优雅
随机落子最直观的写法是:随机生成一个坐标,如果被占了就重新随机一次。在9个格子的棋盘上,只要还有空位,重试几次总能碰中,看起来也没什么问题。但如果棋盘只剩最后一个空位,随机到它的概率是1/9,虽然不会死循环,计算上却是浪费。更重要的是,这种"碰运气"的思路在写更复杂的AI时是走不通的。
所以我在computerMove()里换了一种更工程化的思路:先把所有空格收集到一个表格里,再从表格里随机选一个。
void computerMove() { int emptyPos[9][2]; int count = 0; // 遍历棋盘,把所有空格的下标存起来 for (int i = 0; i < ROW; i++) { for (int j = 0; j < COL; j++) { if (board[i][j] == ' ') { emptyPos[count][0] = i; emptyPos[count][1] = j; count++; } } } if (count > 0) { int index = rand() % count; board[emptyPos[index][0]][emptyPos[index][1]] = 'O'; printf("电脑落子在第%d行第%d列\n", emptyPos[index][0] + 1, emptyPos[index][1] + 1); } }emptyPos是一个二维数组,每一行存一个空格的(行, 列)坐标。这本质上是在给候选位置建索引,后面改智能AI的时候,你还需要遍历所有空格来评估哪个位置最好,这种"收集候选位置"的思路会一直延续下去。
3.4 主循环:让游戏"轮流"转起来
整个游戏是一个循环:玩家落子,检查胜负;如果没结束,电脑落子,再检查胜负;如果还没结束,回到玩家。用checkWin()的返回值控制循环,非常自然。
int main() { srand(time(NULL)); initBoard(); char result = 'C'; // C: 游戏继续, X: 玩家胜, O: 电脑胜, D: 平局 while (result == 'C') { displayBoard(); playerMove(); result = checkWin(); if (result != 'C') { break; } computerMove(); result = checkWin(); } displayBoard(); if (result == 'X') { printf("恭喜你赢了!\n"); } else if (result == 'O') { printf("电脑赢了,看来随机出手也有运气好的一天。\n"); } else { printf("平局!\n"); } return 0; }核心逻辑放在一个while循环里,每轮结束都检查一次状态。为什么要检查两次而不是玩家落子之后直接判断一次?因为如果玩家落子已经赢了,再让电脑落子就毫无意义,而且会破坏棋盘。这个先后顺序是写游戏循环最容易出错的地方,心里要有一根弦:每次落子后都要立刻判断,然后决定下一步是继续还是结束。
4. 胜负与平局判定:最容易写乱的一块逻辑
4.1 三子棋的全部胜利条件:为什么是8种而不是更多
3×3棋盘上要凑成三连,只有三类情况:任意一行、任意一列、两条对角线。行有3种,列有3种,对角线有2种,加起来8种。很多人的第一反应是用穷举法,把每种情况都写出来,8种情况其实就是8个if,但写的时候容易重复判断。我用的办法是三条循环加两个特判,逻辑上更清晰。
char checkWin() { // 检查所有行 for (int i = 0; i < ROW; i++) { if (board[i][0] != ' ' && board[i][0] == board[i][1] && board[i][1] == board[i][2]) { return board[i][0]; } } // 检查所有列 for (int j = 0; j < COL; j++) { if (board[0][j] != ' ' && board[0][j] == board[1][j] && board[1][j] == board[2][j]) { return board[0][j]; } } // 检查主对角线 if (board[0][0] != ' ' && board[0][0] == board[1][1] && board[1][1] == board[2][2]) { return board[0][0]; } // 检查副对角线 if (board[0][2] != ' ' && board[0][2] == board[1][1] && board[1][1] == board[2][0]) { return board[0][2]; } // 没有胜负,检查是否还有空位 for (int i = 0; i < ROW; i++) { for (int j = 0; j < COL; j++) { if (board[i][j] == ' ') { return 'C'; // 还有空位,游戏继续 } } } return 'D'; // 没有空位,且无人获胜,平局 }核心技巧是先判断格子是否为空,再判断三子相等。如果board[i][0]本身就是空格,三个空格相等,条件也会成立,就会把空格当成胜利者返回。我第一次就是漏了!= ' '这个判断,导致棋盘全空时直接判了平局。
4.2 返回值的约定:用字符传状态,读起来自然
这个函数返回char而不是int,因为返回的是赢家的棋子符号:如果X赢返回'X',如果O赢返回'O',没赢但有空格返回'C'(continue),全部填满返回'D'(draw)。调用方拿到返回值之后,既可以判断游戏是否结束,又可以知道谁赢了。这种用返回值传状态的做法在C语言里很常见,比返回0表示没结束、返回1表示X赢、返回2表示O赢这种编码要直观得多,几乎不用查注释就能读懂代码。
4.3 平局判断的时机
平局的本质是"棋盘满了但没人三连"。很多同学会在每次落子后先检查有没有人赢,再检查棋盘是否满了,这样是对的。但有一种写法很隐蔽地出错:在checkWin()里,先检查是否有空的格子,如果有就返回'C',否则返回'D'。这样看起来没错,但顺序必须是先查胜利、后查空格。如果反过来,棋盘满了但有一方赢了,函数会先看到没有空格,返回平局,赢家反而被吞掉了。所以判断顺序和判断条件一样重要。
5. 运行实测与踩坑记录:scanf缓冲区、乱码和死循环
5.1 编译和运行
我的环境是Linux上用的gcc,Windows环境其实也一样。保存代码时注意文件名后缀是.c,然后:
gcc tictactoe.c -o tictactoe ./tictactoeWindows下如果用MSVC或者MinGW:
gcc tictactoe.c -o tictactoe.exe tictactoe.exe如果编译直接通过,说明语法没问题。但如果运行时发现中文乱码,多半是源码文件的编码问题。在Windows的命令行窗口里,默认的代码页是GBK,而很多现代编辑器默认保存为UTF-8,中文提示就会变成乱码。解决办法要么把源码另存为GBK/ANSI编码,要么在代码开头加system("chcp 65001");切换到UTF-8代码页。我当时折腾了十分钟才反应过来是编码问题,后来直接把编译器终端字体换成了支持中文的,才彻底清静。
5.2 为什么输入字母后程序"死机"了
前面提过,scanf读不到整数时,字母会残留在缓冲区里。如果不在playerMove()里清空缓冲区,程序会在同一个scanf调用上反复失败,形成死循环。这个问题在学循环的时候老师会提,但只有自己踩一次坑,才会真正记住。我现在的习惯是:凡是涉及scanf读取用户输入的地方,一律检查返回值,并处理清空缓冲区的逻辑。
5.3 随机数种子引发的"假随机"
我第一次跑程序的时候没写srand(time(NULL)),结果每局游戏电脑的落子轨迹一模一样。当时我一度怀疑是rand()是不是被"记忆"了,查了资料才知道伪随机数的概念。这个坑太有代表性了,很多时候你做的小工具第一遍运行正常,第二遍运行结果还一样,不是代码逻辑问题,而是随机种子没设置。凡是涉及到随机性,记得把srand(time(NULL))放在main开头。
5.4 边界输入测试
我拿自己写的程序测试了几轮,发现只做基础校验还不够。比如输入"0 0"会越界,输入"1 1 1 1 1 1"会多读入一堆数字,虽然程序能扛住,但体验很差。后来我加了一个简单的处理:要求用户每次只输入两个整数,多了就全部清掉。另外,三子棋的坐标范围是1~3,我专门做了越界判断,这一步不漏的话,数组越界的问题就不会出现。
6. 让呆头呆脑的电脑变聪明:三种可落地的AI改进方案
6.1 方案一:防守型AI——先学会堵人
电脑"呆头呆脑"最明显的表现是:玩家下一步就要赢了,电脑还在随机下。第一档改进是让电脑学会堵。在落子之前,遍历所有空格,如果某个空格被玩家占掉之后玩家能赢,电脑就抢先占住这个位置。
int canPlayerWinAt(int x, int y) { board[x][y] = 'X'; int win = (checkWin() == 'X'); board[x][y] = ' '; // 假设完立刻恢复,不影响真实棋盘 return win; } void defensiveComputerMove() { for (int i = 0; i < ROW; i++) { for (int j = 0; j < COL; j++) { if (board[i][j] == ' ' && canPlayerWinAt(i, j)) { board[i][j] = 'O'; return; } } } computerMove(); // 没有需要堵的位置,就还是随机 }这个思路利用了已有的checkWin()函数,先假设玩家落在某个空位,然后判断棋盘状态。这一招也体现了"临时修改再恢复"的编程技巧,在很多算法里都会用到。注意这个AI只能防守,自己不会主动制造三连,所以它还是有点呆,但至少不会放着玩家的两连不管了。
6.2 方案二:攻守兼备型AI——先赢再说,赢不了就堵
第二档改进是在防守之前先看自己能不能赢。如果能赢,直接落子获胜;如果不能赢,再检查是否要堵玩家。这个思路是任何棋类AI最简单的优先级模型:能赢优先,再防守,最后随机兜底。
int canComputerWinAt(int x, int y) { board[x][y] = 'O'; int win = (checkWin() == 'O'); board[x][y] = ' '; return win; } void smartComputerMove() { // 自己能赢就先赢 for (int i = 0; i < ROW; i++) { for (int j = 0; j < COL; j++) { if (board[i][j] == ' ' && canComputerWinAt(i, j)) { board[i][j] = 'O'; return; } } } // 玩家要赢了就堵 for (int i = 0; i < ROW; i++) { for (int j = 0; j < COL; j++) { if (board[i][j] == ' ' && canPlayerWinAt(i, j)) { board[i][j] = 'O'; return; } } } // 占中心比随机好一点,因为中心参与四条线 if (board[1][1] == ' ') { board[1][1] = 'O'; return; } // 实在没思路就随机 computerMove(); }这个版本已经能在多数情况下和初学者打得有来有回了。它的战术单一但有效,哪怕玩家先手,也不容易立刻把电脑打崩。实话说,这个档次的AI在这个小棋盘上已经足够让"呆头呆脑"这个标题名不副实,但对学习过程来说,它是个很自然的递进。
6.3 方案三:完美AI——基于状态搜索的minimax算法
如果想把电脑做得真正不败,需要引入极大极小值搜索(minimax)。核心思路是:枚举所有可能的落子位置,把每种走法后的棋盘状态用分数评估,双方都假设对方会走对自己最有利的那一步,最后挑选综合得分最高的落点。三子棋的状态空间很小,全搜索完全可行,代码量大约在100行左右。这个方向适合把C语言基础打牢之后再挑战,同时也会涉及一点递归——很多同学第一次真正理解递归,就是从这类棋盘搜索问题开始的。
这个话题展开可以单独写一篇,我在这里不贴完整代码,但建议有兴趣的同学务必做一次。做完minimax之后你会深刻理解三个概念:递归搜索、状态评估、博弈树剪枝。这三样东西在更复杂的AI里是基本功。
6.4 扩展思路:文件存档与动态棋盘
围棋棋盘改进之后,还可以往两边扩展。比如用fprintf和fscanf把对局记录保存到文件里,下到一半存档,下次启动时读档继续。这个扩展直接练到文件读写,是C语言里容易让人头疼但又躲不开的知识点。再比如把棋盘从3×3推广到N×N,胜负判定改成通用检查——先检查行、再检查列,最后检查两条对角线,判断逻辑会变得更抽象,但对数组、循环、以及边界条件的理解会再上一个台阶。
7. 做完这个项目之后的一点个人建议
最后分享一点我自己的体会。写完三子棋之后,我做了一个决定:每学一个C语言知识点,就回头改进一次这个游戏。学到文件操作,就给游戏加存档功能;学到指针和动态内存,就尝试用指针代替固定数组;学到递归,就研究minimax。一个小项目就像一棵树,你不断往上面嫁接新知识,它会越长越茂盛,比不断开新项目要扎实得多。
如果你现在也卡在"学完语法但写不出东西"的阶段,别犹豫,找个小游戏题目直接上手。三子棋、猜数字、简易计算器、万年历,选一个开始。代码写得再烂、再丑都没关系,跑不跑得通才是关键。跑通了,再回头看知识点,你会发现自己突然就"会了"。