☰
C语言实战:用二维数组与函数实现三子棋小游戏
2026/9/30 6:32:41 网站建设 项目流程

玩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子棋”。又比如可以用文件操作把每一局对战记录写入日志,下次启动时能复盘。

但我不建议你一上来就把这些全加上。写这个小项目的正确姿势是:

  1. 先把最基础的版本跑通(单文件、随机电脑、能判胜负);
  2. 再拆分成多文件,理清接口和职责边界;
  3. 再优化电脑策略或加清屏、延迟这些体验细节;
  4. 最后才是往“大而全”方向扩展。

一步一步来,每步都能编译、能运行、能测试,这样出了问题也知道是哪个环节引入的。

5.2 对C语言初学者的价值所在

这个项目的训练点其实覆盖了C语言入门阶段几乎所有的核心语法:

  • 数组与二维数组的定义、遍历、传参;
  • 函数的声明、定义、返回值、参数传递;
  • 循环与分支的嵌套使用;
  • 输入输出函数scanf/printf的格式化与返回值判定;
  • 预处理指令#define和头文件守卫;
  • 随机数的生成与种子设置;
  • 逻辑运算与状态标志的设计思维。

你可能每节课都学了一个知识点,但到了这个小项目里,这些点全都串起来了。这也是为什么很多课程都会把三子棋当作阶段性大作业的原因——它小到一晚上能写完,又大到足够检验你对基础知识的掌握程度。

5.3 个人的一点实操感受

我写这个项目最大的收获不是“我会写三子棋了”,而是明白了“设计在前、编码在后”这回事。最开始我拿到题目直接噼里啪啦写一个main里塞了所有逻辑,写到最后连自己都快看不懂了,稍微改一个功能就得全盘推倒重来。后来静下心,先把状态机想清楚,把函数划分好,再动笔,反而快得多。

特别是IsWin返回字符的设计,让主循环的判断逻辑变得异常清爽。这种“函数返回一个特殊值来表达状态”的思路,在以后写更复杂的程序时同样适用——状态机、错误码、退出码都是这个思想的延伸。建议你写完这个项目后,试着把电脑策略改成“只防守不进攻”,或者让电脑先手,在这些小改动中体会状态和策略设计对代码结构的影响。动手改一遍,远比看十遍别人的代码更有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询