ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言实战:用二维数组与函数实现三子棋小游戏

C语言实战:用二维数组与函数实现三子棋小游戏 玩C语言的人多少都有过“自己写个小游戏”的念头。不用图形界面、不用复杂引擎就在黑乎乎的终端里跑起来也够有成就感。三子棋井字棋就是这类练手项目里最经典的一个规则简单、逻辑清晰、实现起来不烧脑但又刚好能把数组、函数、循环、条件判断这些C语言基础知识点全串起来。我这次做的这个“呆头呆脑的电脑”版本玩法就是玩家和电脑轮流在3x3棋盘上落子先连成一条线横、竖、斜都行就算赢。项目本身不大完整工程也就三四个文件、几百行代码但对初学C语言的朋友来说信息量其实很足。你会接触到二维数组怎么建模棋盘、怎么用循环做输入校验、怎么设计一个“不太聪明”的电脑落子策略以及怎么把游戏主循环组织得清晰不乱。这篇文章不会只丢一堆代码给你我会把每一步的设计思路、为什么这样写、坑在哪里都讲清楚哪怕你刚学到指针和结构体那块也能跟得上。1. 项目整体设计与思路拆解1.1 先想清楚游戏状态机再动手写任何程序尤其是带交互逻辑的程序最忌讳的就是拿到需求直接敲键盘。三子棋看着简单但如果不先把“游戏处在什么阶段、下一步该干什么”理清楚代码很容易写得像一团乱麻。我习惯先把游戏的状态转移画在脑子里或草稿纸上。三子棋的状态其实很单纯初始化状态棋盘清空玩家先手或电脑先手我这里设定玩家先手玩家落子状态等待玩家输入坐标校验合法性落子电脑落子状态电脑根据当前棋盘计算位置落子胜负判定状态每次落子后检查是否有人连成三子或者棋盘已满平局结束状态显示结果询问是否再来一局。这个状态机确定了主循环的骨架就出来了。每次循环处理一次“玩家走电脑走”每次落子后立刻判断输赢平。骨架清晰后面每个模块往里填就顺理成章。1.2 为什么棋盘用二维数组而不是一维三子棋棋盘是3x3的方格C语言里最直观的建模方式就是二维数组char board[3][3]。每个元素存一个字符用空格表示空位、X表示玩家落子、O表示电脑落子。之所以用char而不是int是因为我们打时直接打印字符数组特别方便本质上棋盘就是一张“字符画”。当然也能用一维数组char board[9]然后通过row * 3 col把二维坐标映射成一维下标。这两种方案都可以但二维数组的语义更贴近棋盘本身行和列的概念直接对应写着不费脑子。项目里选二维数组还有一个原因后面做胜负判定时七个赢法三行、三列、两斜线可以直接用下标去遍历代码可读性会好很多。1.3 模块化拆分三个文件各司其职这个项目虽然小我还是按工程化的方式拆成了三个文件main.c程序入口负责主循环和玩家交互game.h头文件放函数声明、宏定义和全局常量game.c游戏核心逻辑实现包括初始化、打印、落子、判定等。好处显而易见main.c只关心“流程”game.c只关心“规则”。以后你想把这个命令行游戏改成带GUI的版本核心判定逻辑可以直接复用只需要替换输入输出部分。对初学者来说提前养成这种“接口与实现分离”的习惯比多写几百行代码更有价值。2. 核心功能模块分析与实现要点2.1 棋盘初始化与显示一切从打印开始初始化其实特别简单就是把棋盘数组的每个格子填成空格void InitBoard(char board[ROW][COL], int row, int col) { for (int i 0; i row; i) { for (int j 0; j col; j) { board[i][j] ; } } }这里的ROW和COL我在game.h里定义成了宏统一为3。虽然这道题固定是3x3但写成宏之后以后想改成五子棋的15x15棋盘只需要改宏定义并扩展判定逻辑就行不用满世界找魔法数字。打印棋盘是大家最容易忽略但最容易翻车的部分。三子棋的棋盘由格子和分隔线组成我用的打印格式是这样的void DisplayBoard(char board[ROW][COL], int row, int col) { for (int i 0; i row; i) { for (int j 0; j col; j) { printf( %c , board[i][j]); if (j col - 1) printf(|); } printf(\n); if (i row - 1) { for (int j 0; j col; j) { printf(---); if (j col - 1) printf(|); } printf(\n); } } }输出的效果是| | ----------- | | ----------- | |注意j col - 1这个条件它保证最后一列后面不会多打一个竖线分隔行最后一列也不会多一个|否则棋盘右侧会多出一条线强迫症很难受。这种细节在代码里不起眼但恰恰是这类小项目“干不干净”的分水岭。2.2 玩家落子输入校验是重头戏玩家落子的函数要处理三件事提示输入坐标、判断坐标是否越界、判断该位置是否已有棋子。这里最容易出问题的是“输入缓冲残留”——比如玩家输入了一个非数字字符scanf会失败但坏字符还留在缓冲区导致程序陷入死循环。我的做法是写一个专门的输入函数用scanf的返回值来判断是否真的读到了两个整数void PlayerMove(char board[ROW][COL], int row, int col) { int x 0, y 0; printf(玩家落子请输入坐标行 列); while (1) { if (scanf(%d %d, x, y) ! 2) { // 清空缓冲区 while (getchar() ! \n); printf(输入格式错误请重新输入两个整数例如1 1); continue; } if (x 1 || x row || y 1 || y col) { printf(坐标越界请输入 1~%d 之间的数, row); continue; } if (board[x - 1][y - 1] ! ) { printf(这个位置已经有棋子了换一个位置吧); continue; } board[x - 1][y - 1] X; break; } }这块有几点经验想说坐标用 1~3 而不用 0~2是照顾玩家习惯。内部存储时减1映射到数组下标就行处理scanf失败时必须用while (getchar() ! \n);把缓冲区的脏数据全部吃掉否则下次循环scanf又读到同一个坏字符形成死循环键盘输入“10 2”这类越界值同样要给出明确提示而不是直接崩溃。2.3 电脑落子策略笨办法也有取舍标题写的是“呆头呆脑的电脑”所以电脑的AI不需要多聪明。最简单的实现就是随机落子生成一个随机的位置如果该位置为空就落子否则重新随机。技术上唯一要注意的是随机种子srand((unsigned)time(NULL))要放在主函数里调用一次而不是放在电脑落子函数里反复调用否则时间间隔太短会导致随机数几乎不变化。void ComputerMove(char board[ROW][COL], int row, int col) { int x 0, y 0; printf(电脑落子中...\n); while (1) { x rand() % row; y rand() % col; if (board[x][y] ) { board[x][y] O; break; } } }但有基础的读者可能觉得这样太“蠢”了电脑完全不会防守玩家只要先占角就必胜。所以我加了个“稍微聪明一丢丢”的优化电脑如果发现自己下一步能赢就优先走那步如果没有就看玩家有没有两步连珠主动去堵一下。这个策略写成代码大概就是遍历所有空位模拟落子后调用胜负判定函数如果电脑赢就选它如果玩家赢就堵它。哪怕不做这种优化只写随机版本的电脑也是能玩的——不过实测下来玩家几乎必赢玩两把就腻了。建议你在基础版跑通之后再加上这个简单策略代码量增加不多但可玩性提升明显。2.4 胜负判定七个赢法一网打尽3x3的棋盘赢法就三种类型三行、三列、两条对角线。我用一个函数统一判断char IsWin(char board[ROW][COL], int row, int col) { // 检查行 for (int i 0; i row; i) { if (board[i][0] board[i][1] board[i][1] board[i][2] board[i][0] ! ) return board[i][0]; } // 检查列 for (int j 0; j col; j) { if (board[0][j] board[1][j] board[1][j] board[2][j] board[0][j] ! ) return board[0][j]; } // 检查对角线 if (board[0][0] board[1][1] board[1][1] board[2][2] board[0][0] ! ) return board[0][0]; if (board[0][2] board[1][1] board[1][1] board[2][0] board[0][2] ! ) return board[0][2]; // 平局 if (IsFull(board, row, col)) return Q; return C; // 继续 }返回值的设计很关键。函数返回的是“赢家是谁”用字符区分X玩家赢O电脑赢Q平局C继续。这样主循环里只需一行代码就能判断状态。IsFull是判断棋盘是否已满的辅助函数遍历数组看是否还有空格即可。写判定函数最忌讳的是把代码写死成board[0][0] board[0][1]这样一串虽然能用但今天三子棋还好以后扩展到更大的棋盘就完全没法维护。我用row和col参数做成通用型写法虽然多写几个循环但代码更健康。2.5 主循环让流程像流水线一样清楚主循环的逻辑其实就几句话但顺序不能乱while (1) { DisplayBoard(board, ROW, COL); PlayerMove(board, ROW, COL); if (IsWin(board, ROW, COL) ! C) break; ComputerMove(board, ROW, COL); if (IsWin(board, ROW, COL) ! C) break; }每次玩家落子后立刻判一次胜负电脑落子后再判一次。注意这里没法用switch之类的结构来“按下一次落子权”之类的复杂状态因为棋类游戏本来就是严格交替的每轮两个动作固定。等循环结束时再用IsWin的结果表格输出谁赢了。有个小细节电脑落子之前我故意加了一句printf(电脑落子中...\n);并且用Sleep(500)Windows下需要#include windows.h延迟半秒让电脑“思考”有真实感。这个不是功能必需品但玩家体验会好很多不然电脑秒落子会觉得特别假。3. 完整代码实现与运行演示3.1 game.h接口定义一览#ifndef GAME_H #define GAME_H #include stdio.h #include stdlib.h #include time.h #define ROW 3 #define COL 3 // 初始化棋盘 void InitBoard(char board[ROW][COL], int row, int col); // 打印棋盘 void DisplayBoard(char board[ROW][COL], int row, int col); // 玩家落子 void PlayerMove(char board[ROW][COL], int row, int col); // 电脑落子 void ComputerMove(char board[ROW][COL], int row, int col); // 判断游戏状态 char IsWin(char board[ROW][COL], int row, int col); // 判断棋盘是否已满 int IsFull(char board[ROW][COL], int row, int col); #endif头文件写清楚了每个函数是干什么的别人拿到源码不需要看实现就能理解整体功能。这个习惯请务必养成哪怕你的项目只有自己一个人看。头文件的作用就是“契约”把接口定下来实现随便改调用方不用动。3.2 game.c核心逻辑逐个落地除了上面已经展示过的代码这里我把IsFull也和盘托出int IsFull(char board[ROW][COL], int row, int col) { for (int i 0; i row; i) { for (int j 0; j col; j) { if (board[i][j] ) return 0; } } return 1; }IsWin里的IsFull调用顺序也有讲究必须先判断输赢再判断平局。因为如果最后一步恰好赢了棋盘同时是满的这时应该返回赢家而不是平局。我的代码里先遍历行、列、对角线都检查完了才判断是否满盘这个顺序保证了赢家优先级高于平局。电脑落子如果要做简单的“防守/进攻”策略实现也不复杂我可以给你一个加料版的参考void ComputerMove(char board[ROW][COL], int row, int col) { // 先尝试自己赢 for (int i 0; i row; i) { for (int j 0; j col; j) { if (board[i][j] ) { board[i][j] O; if (IsWin(board, row, col) O) { return; } board[i][j] ; } } } // 再尝试堵玩家 for (int i 0; i row; i) { for (int j 0; j col; j) { if (board[i][j] ) { board[i][j] X; if (IsWin(board, row, col) X) { board[i][j] O; return; } board[i][j] ; } } } // 最后随机落子 int x 0, y 0; do { x rand() % row; y rand() % col; } while (board[x][y] ! ); board[x][y] O; }这个策略的思路是“模拟两步棋”先把空位填上电脑的棋子看看会不会赢再把空位填上玩家的棋子看看玩家会不会赢如果会就堵住。两个循环都走完还没找到好位子就随机走。这个版本实测下来电脑虽然还不至于“聪明”但已经不是纯沙包了有时候还能和玩家周旋几轮。这种“模拟评估”的思路其实就是以后接触更复杂的博弈算法比如极小化极大的基础入门姿势。3.3 main.c从进入程序到再见全流程#include game.h void menu() { printf(##############################\n); printf(### 1. 开始游戏 ###\n); printf(### 0. 退出 ###\n); printf(##############################\n); } void game() { char board[ROW][COL]; InitBoard(board, ROW, COL); char ret C; while (ret C) { DisplayBoard(board, ROW, COL); PlayerMove(board, ROW, COL); ret IsWin(board, ROW, COL); if (ret ! C) break; ComputerMove(board, ROW, COL); ret IsWin(board, ROW, COL); } DisplayBoard(board, ROW, COL); if (ret X) printf(恭喜你赢了\n); else if (ret O) printf(电脑赢了再接再厉\n); else printf(平局旗鼓相当\n); } int main() { int input 0; srand((unsigned int)time(NULL)); do { menu(); printf(请选择); if (scanf(%d, input) ! 1) { while (getchar() ! \n); printf(输入无效请重新选择\n); continue; } switch (input) { case 1: game(); break; case 0: printf(再见欢迎再来\n); break; default: printf(选择无效请输入 0 或 1。\n); break; } } while (input ! 0); return 0; }主函数用do...while是因为菜单至少要显示一次用switch处理分支比较清爽。srand放在main开头整个程序运行期间只调用一次避免随机数不随机的问题。3.4 运行效果完整复盘我用实际环境跑了一把过程大概是这样的程序启动显示菜单输入1回车棋盘清空后打印空盘玩家先手我输入“1 1”占左上角电脑随机落了一子位置在中间我输入“1 2”占第一行第二列电脑这下走了一手“防守棋”堵住我的第一步连线你来我往几步之后我在第一行三个位置全部占满系统弹出“恭喜你赢了”本轮结束按0退出程序。从体验上来讲加了策略版的电脑确实会让游戏更有张力。你甚至会发现自己需要多动脑筋设计陷阱而不是闭着眼睛乱点就能赢。对写代码的人来说这种感觉还挺妙的——明明每一步逻辑都很简单但组合在一起就产生了一点“智能感”。4. 常见问题与排查技巧实录4.1 scanf输入失灵缓冲区残留问题这是新手最容易撞上的坑。比如你输入1 1后回车scanf正确读走了两个数字但缓冲区里的换行符还留在原地。如果你的程序某处用了getchar()之类的函数去读字符就会读到这个残留的换行导致行为异常。更加灾难的是输入a这种非数字字符时scanf返回0但a并没有被消费如果不做缓冲区清理下次循环读到同样的a于是死循环。解决方案就是我前面展示的if (scanf(%d, input) ! 1) { while (getchar() ! \n); continue; }这里while (getchar() ! \n);是一个经典写法它会把当前缓冲区直到换行符之前的所有字符统统读走并丢弃。注意分号结尾代表循环体是空语句这个空语句的写法经常被漏掉分号导致循环体变成下一行编译报错。4.2 电脑落子陷入死循环随机版本的ComputerMove有个最坏情况如果整个棋盘只剩一个空格但rand()总随机到非空格的位置理论上可能循环很多次。虽然因为每次重新随机命中那个空格的概率是1/9实际几乎不可能卡死但严谨地讲这个函数不是一个“有限步终止”的算法。更优雅的写法是先把所有空位收集起来再随机挑一个int empty[9][2]; int count 0; for (int i 0; i row; i) for (int j 0; j col; j) if (board[i][j] ) { empty[count][0] i; empty[count][1] j; count; } if (count 0) { int idx rand() % count; board[empty[idx][0]][empty[idx][1]] O; }这样代码更可控也不会有无效循环。我在自己的版本里最开始用do...while后来为了做一个“电脑不可能卡死”的演示改成了收集空位再随机的方式实测体验无差别但代码更让人放心。4.3 清屏函数跨平台问题我用的system(cls)是Windows专属的。如果你的代码要拿到macOS或Linux上编译cls不会被识别需要换成system(clear)。用system调系统命令本身不是好习惯有安全性和性能问题但在教学项目里为了演示效果大家几乎都这么用。如果不想依赖系统命令还有一个办法多打印几个空行来“假装”清屏void clearScreen() { printf(\n\n\n\n\n\n\n\n\n\n); }这种做法虽然不优雅但跨平台零成本也不会引入windows.h依赖。我自己实际发布这个项目时用的是system(cls)因为目标读者基本都在Windows Dev-C或VS的环境里跑最简单直接。4.4 编译报错函数未声明新手最常见的问题之一是编译时报implicit declaration of function错误。这通常是因为你在main.c里调用了game()函数但这个函数定义在game()函数之前没有声明。解决办法有两种要么在main.c顶部加上函数声明或者把menu和game定义在main之前要么像我一样用头文件game.h统一声明。如果用的是我这种多文件结构千万记得把game.c也加入编译否则链接期会报一堆“未定义的引用”。我用VS的时候经常遇到新手把game.c忘了添加进项目里然后在链接时报错一头雾水。排查思路很简单工程里有没有包含这个.c文件没有就右键源文件添加现有项把它加进去。4.5 常见问题速查表现象可能原因解决方案输入数字后程序无反应或跳不出循环scanf没有消费掉缓冲区中的非法字符用while(getchar() ! \n);清空缓冲区电脑落子永远是同一个位置srand没有被调用或放在循环里反复调用在main开头调用一次srand((unsigned)time(NULL))越界输入导致程序崩溃没有判断玩家输入坐标是否在1~3范围内在PlayerMove中加入越界检查明明三点连成一线但没判赢判定函数中的board[0] ! 条件缺失检查IsWin中是否排除空格三连的情况菜单界面可以输入负数scanf返回值判断不严谨检查返回值并判断输入值只能在0和1之间编译通过但运行后闪退可能是game.c没被加入编译在工程中添加game.c文件电脑从不堵玩家的棋电脑策略只有随机落子升级为“模拟落子查看是否输赢”的防守/进攻策略5. 一个细节控的项目回顾与扩展思路5.1 每个模块的优化空间在哪里现在回头看看这个项目其实有不少地方还能继续打磨。比如电脑的策略可以升级为“极小化极大”算法让电脑完美走棋达到必输/必和/必胜的状态。再比如可以把棋盘做成动态大小的宏换成n支持 5x5 甚至更大的“N子棋”。又比如可以用文件操作把每一局对战记录写入日志下次启动时能复盘。但我不建议你一上来就把这些全加上。写这个小项目的正确姿势是先把最基础的版本跑通单文件、随机电脑、能判胜负再拆分成多文件理清接口和职责边界再优化电脑策略或加清屏、延迟这些体验细节最后才是往“大而全”方向扩展。一步一步来每步都能编译、能运行、能测试这样出了问题也知道是哪个环节引入的。5.2 对C语言初学者的价值所在这个项目的训练点其实覆盖了C语言入门阶段几乎所有的核心语法数组与二维数组的定义、遍历、传参函数的声明、定义、返回值、参数传递循环与分支的嵌套使用输入输出函数scanf/printf的格式化与返回值判定预处理指令#define和头文件守卫随机数的生成与种子设置逻辑运算与状态标志的设计思维。你可能每节课都学了一个知识点但到了这个小项目里这些点全都串起来了。这也是为什么很多课程都会把三子棋当作阶段性大作业的原因——它小到一晚上能写完又大到足够检验你对基础知识的掌握程度。5.3 个人的一点实操感受我写这个项目最大的收获不是“我会写三子棋了”而是明白了“设计在前、编码在后”这回事。最开始我拿到题目直接噼里啪啦写一个main里塞了所有逻辑写到最后连自己都快看不懂了稍微改一个功能就得全盘推倒重来。后来静下心先把状态机想清楚把函数划分好再动笔反而快得多。特别是IsWin返回字符的设计让主循环的判断逻辑变得异常清爽。这种“函数返回一个特殊值来表达状态”的思路在以后写更复杂的程序时同样适用——状态机、错误码、退出码都是这个思想的延伸。建议你写完这个项目后试着把电脑策略改成“只防守不进攻”或者让电脑先手在这些小改动中体会状态和策略设计对代码结构的影响。动手改一遍远比看十遍别人的代码更有用。
返回列表