2-5 井字棋
⏱ 本节预计用时:120分钟
恭喜你走到了这里!
这一节,我们将写好井字棋的核心逻辑,下一章会把它拆分成多个文件,并且加入读档存档功能。
1 需求分析:想明白要做一个什么样的软件
在写代码之前,一个软件工程师要做的第一件事不是打开编辑器,而是分析需求,我们要做一个什么样的软件?
我知道你现在是懵逼状态,跟着我的思路走。
想一想你打过的游戏。
进入游戏,首先得显示一个主菜单页面,这里我们做一个最简单的,只显示:新游戏 / 退出
我们还没有学图形库,没有办法画出想你现在打开的软件这样的界面,现在只能在终端里做界面,等第五章学完图形库之后我们会重写现在的项目,让它变成一个真正的图形化软件。
=================================
井 字 棋 -- 主菜单
=================================
1. 开始新游戏
2. 退出游戏
请输入数字(1-2):
选择1,开始新游戏;选择2,退出。
进行游戏的时候,需要画个棋盘,需要说明当前是谁落子,玩家输入落子的位置,就能在对应的地方落子。可能有用户半路不想玩了,所以还要做一个游戏菜单,可以决定是否退出游戏。
1 2 3 <- 列号
| |
---+---+---
| |
---+---+---
| |
>>> 轮到 [玩家1] (X) 落子
---------------------------------
游戏菜单
1. 继续游戏
2. 返回主菜单
请输入数字(1-2):1
请输入落子位置(行号 列号,例如:1 1):1 1
每个玩家下完后,系统判断是否他赢了,或者是否棋盘下满了平局了,显而易见,这里要做一个判断逻辑,如果有人赢(或者平局)了,就切换到一个新的恭喜页面。
恭喜!玩家1 获胜!
这就是咱们要做的全部功能。
2 落实需求:一步一步做出来
先记住一个原则:不要一上来就写最终版,一步一步来。
下面我们把需求拆成很多小步,一次只加一个功能。每做完一步,都立刻编译运行,亲眼看到效果,再进入下一步。每走一步,你的程序都比上一步更强一点——这就是正反馈。
第 1
3 步、第 7、8 步给的是完整代码;第 46、9、10 步是在上一步的基础上"加一点东西",照着改就行。改乱了也没关系,下一步往往有完整代码可以对照。
2.1 第 1 步:先让主菜单跑起来
游戏的第一步,是那个主菜单。这一节我们只做菜单:
- 选 1 → 先打印一句话占位(真正的游戏,下一步再做)
- 选 2 → 退出
- 选其他数字 → 提示重新输入
#include <stdio.h>
int main(void)
{
int choice;
while (1) {
printf("=================================\n");
printf(" 井 字 棋 -- 主菜单\n");
printf("=================================\n");
printf(" 1. 开始新游戏\n");
printf(" 2. 退出游戏\n");
printf("请输入数字(1-2):");
scanf("%d", &choice);
if (choice == 1) {
printf("开始新游戏!\n");
printf("(棋盘还没画,下一步就来)\n");
} else if (choice == 2) {
printf("再见!\n");
break;
} else {
printf("输入不合法,请重新输入!\n");
}
}
return 0;
}
运行看看:
=================================
井 字 棋 -- 主菜单
=================================
1. 开始新游戏
2. 退出游戏
请输入数字(1-2):2
再见!
虽然还不能下棋,但你已经有一个"能选菜单、能退出"的程序了。
2.2 第 2 步:把棋盘画出来
选 1 之后,我们要能看到棋盘。棋盘用我们学过的二维数组来表示:
int board[3][3];
board[行][列],0 表示空,以后 1 表示 X,2 表示 O(现在只用到 0)。
选 1 之后做两件事:先清空棋盘(双重循环全部填 0),再用双重循环一行一行把格子画出来。
#include <stdio.h>
/* 棋盘:0 表示空,1 表示 X,2 表示 O */
int board[3][3];
int main(void)
{
int row, col;
int choice;
while (1) {
printf("=================================\n");
printf(" 井 字 棋 -- 主菜单\n");
printf("=================================\n");
printf(" 1. 开始新游戏\n");
printf(" 2. 退出游戏\n");
printf("请输入数字(1-2):");
scanf("%d", &choice);
if (choice == 1) {
/* 清空棋盘 */
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
board[row][col] = 0;
}
}
/* 画出空棋盘 */
printf("\n 1 2 3 <- 列号\n");
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
printf(" "); /* 棋盘空着,全是空格 */
if (col < 2) {
printf("|");
}
}
printf("\n");
if (row < 2) {
printf("---+---+---\n");
}
}
} else if (choice == 2) {
printf("再见!\n");
break;
} else {
printf("输入不合法,请重新输入!\n");
}
}
return 0;
}
运行效果:选 1,屏幕上真的出现了一个小棋盘。是不是很哇塞。
1 2 3 <- 列号
| |
---+---+---
| |
---+---+---
| |
2.3 第 3 步:在棋盘上落子
棋盘有了,现在让玩家把棋子放上去。
思路:画棋盘 → 让玩家输入行号和列号 → 把对应的格子改成 1(X)→ 再画一遍,棋子就显示出来了。
注意一个"差 1"的问题:玩家输入的是 13,数组下标是 02,所以要写 board[行 - 1][列 - 1]。这个"差 1"以后会经常遇到。
在上一版的基础上,把"选 1 之后"的部分改成一个游戏主循环(画 → 落子 → 再画 → 再落子……):
#include <stdio.h>
/* 棋盘:0 表示空,1 表示 X,2 表示 O */
int board[3][3];
int main(void)
{
int row, col;
int posRow, posCol;
int choice;
while (1) {
printf("=================================\n");
printf(" 井 字 棋 -- 主菜单\n");
printf("=================================\n");
printf(" 1. 开始新游戏\n");
printf(" 2. 退出游戏\n");
printf("请输入数字(1-2):");
scanf("%d", &choice);
if (choice == 1) {
/* 清空棋盘 */
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
board[row][col] = 0;
}
}
/* 游戏主循环:画棋盘 -> 落子 -> 再画 */
while (1) {
/* 画棋盘 */
printf("\n 1 2 3 <- 列号\n");
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == 1) {
printf(" X ");
} else if (board[row][col] == 2) {
printf(" O ");
} else {
printf(" ");
}
if (col < 2) {
printf("|");
}
}
printf("\n");
if (row < 2) {
printf("---+---+---\n");
}
}
/* 输入落子位置(先临时用 0 0 退出游戏) */
printf("请输入落子位置(行号 列号,例如:1 1;输入 0 0 退出):");
scanf("%d %d", &posRow, &posCol);
if (posRow == 0 && posCol == 0) {
break; /* 回到主菜单 */
}
/* 放到棋盘上(输入是1~3,数组下标是0~2) */
board[posRow - 1][posCol - 1] = 1;
}
} else if (choice == 2) {
printf("再见!\n");
break;
} else {
printf("输入不合法,请重新输入!\n");
}
}
return 0;
}
运行效果:输入 1 1,左上角出现一个 X;输入 1 3,右上角出现一个 X……可以一直放。输入 0 0 回到主菜单(临时做法,第 7 步我们做正经的游戏菜单)。
现在先假设玩家输入的都是合法的,第 8 步我们再来"防呆",处理比如说位置越界、下到已经有棋子的地方等边界情况。
能在棋盘上落子了! 现在的问题是只能放 X,也不判断输赢——让我们把这些功能加上去。
2.4 第 4 步:两个玩家轮流落子
现在轮到谁,就用谁的棋子。加一个变量 currentPlayer(1 或 2):
- 开局
currentPlayer = 1,玩家 1 先走 - 每下一子就换人:1 → 2,2 → 1
在第 3 步代码的基础上做 3 处小改动:
改动 1:在 main 的变量声明里加一个变量。
int currentPlayer; /* 当前轮到谁:1 或 2 */
改动 2:在"清空棋盘"之后、"游戏主循环"之前,加一行,让玩家 1 先走。
currentPlayer = 1;
改动 3:把游戏循环里的"输入位置"这一小段,换成下面这样(多了轮到的提示、用 currentPlayer 落子、落完换人)。
/* 提示轮到谁 */
if (currentPlayer == 1) {
printf(">>> 轮到 [玩家1] (X) 落子\n");
} else {
printf(">>> 轮到 [玩家2] (O) 落子\n");
}
/* 输入落子位置 */
printf("请输入落子位置(行号 列号,例如:1 1;输入 0 0 退出):");
scanf("%d %d", &posRow, &posCol);
if (posRow == 0 && posCol == 0) {
break;
}
/* 放当前玩家的棋子 */
board[posRow - 1][posCol - 1] = currentPlayer;
/* 换人 */
if (currentPlayer == 1) {
currentPlayer = 2;
} else {
currentPlayer = 1;
}
运行效果:提示会变成"轮到 [玩家1] (X) 落子",落完一子后,提示变成"轮到 [玩家2] (O) 落子",棋盘上同时出现 X 和 O。
**两个人能轮流下棋了!**下一步我们加上判断输赢。
2.5 第 5 步:判断谁赢了
这是井字棋最核心的逻辑:判断输赢。
思路:落完一子后,检查当前玩家的棋子有没有在同一行、同一列、或一条对角线上连成三个。
先在变量声明里加一个 win:
int win; /* 1 表示有人赢了 */
然后在"落子"之后、"换人"之前,加上判断(用到我们学过的循环和 &&):
/* 判断当前玩家是否赢了 */
win = 0;
/* 检查三行 */
for (row = 0; row < 3 && win == 0; row = row + 1) {
if (board[row][0] == currentPlayer && board[row][1] == currentPlayer && board[row][2] == currentPlayer) {
win = 1;
}
}
/* 检查三列 */
for (col = 0; col < 3 && win == 0; col = col + 1) {
if (board[0][col] == currentPlayer && board[1][col] == currentPlayer && board[2][col] == currentPlayer) {
win = 1;
}
}
/* 检查两条对角线 */
if (win == 0 && board[0][0] == currentPlayer && board[1][1] == currentPlayer && board[2][2] == currentPlayer) {
win = 1;
}
if (win == 0 && board[0][2] == currentPlayer && board[1][1] == currentPlayer && board[2][0] == currentPlayer) {
win = 1;
}
if (win == 1) {
printf("\n恭喜!玩家%d 获胜!\n", currentPlayer);
break; /* 本局结束,回到主菜单 */
}
运行效果:玩家 1 摆出一行三个 X,屏幕上立刻显示"恭喜!玩家1 获胜!",然后回到主菜单。
接下来让我们处理平局的情况。
2.6 第 6 步:处理平局
赢不了也可能下满。加一个判断:棋盘上一个空位都没有,而且没人赢 → 平局。
在变量声明里加一个 full:
int full; /* 1 表示棋盘下满 */
然后在赢的判断(if (win == 1) {...} 那一段)之后、"换人"之前,加上平局的判断:
/* 判断是否平局:棋盘下满了 */
full = 1;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == 0) {
full = 0;
}
}
}
if (full == 1) {
printf("\n棋盘下满,平局!\n");
break;
}
运行效果:两个人把 9 个格子填满还没人赢,显示"棋盘下满,平局!"。
至此,一局棋有了完整的结局:要么有人赢,要么平局。
2.7 第 7 步:画游戏菜单,可以中途退出
之前是用"输入 0 0"退出的,太丑了。现在换成需求里的正经做法:每回合先问一次游戏菜单,选 1 继续、选 2 返回主菜单。
改动 1:加一个变量。
int gameChoice; /* 游戏菜单的选择 */
改动 2:把"输入 0 0 退出"的判断删掉,换成游戏菜单(放在"轮到谁"的提示之后、输入位置之前)。
/* 游戏菜单:可以中途退出 */
printf("---------------------------------\n");
printf(" 游戏菜单\n");
printf(" 1. 继续游戏\n");
printf(" 2. 返回主菜单\n");
printf("请输入数字(1-2):");
scanf("%d", &gameChoice);
if (gameChoice == 2) {
printf("已返回主菜单。\n");
break;
}
改动 3:输入位置的提示去掉";输入 0 0 退出"。
如果中间哪一步改乱了,直接用下面这份完整的第 7 步代码。
到这里,需求里的全部功能都齐了。这已经是一个完整的可以玩的游戏了。
完整代码:
#include <stdio.h>
/* 棋盘:0 表示空,1 表示 X,2 表示 O */
int board[3][3];
int main(void)
{
int row, col;
int posRow, posCol;
int currentPlayer; /* 当前轮到谁:1 或 2 */
int win; /* 1 表示有人赢了 */
int full; /* 1 表示棋盘下满 */
int gameChoice; /* 游戏菜单的选择 */
int choice; /* 主菜单的选择 */
while (1) {
printf("=================================\n");
printf(" 井 字 棋 -- 主菜单\n");
printf("=================================\n");
printf(" 1. 开始新游戏\n");
printf(" 2. 退出游戏\n");
printf("请输入数字(1-2):");
scanf("%d", &choice);
if (choice == 1) {
/* 清空棋盘 */
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
board[row][col] = 0;
}
}
/* 玩家1先走 */
currentPlayer = 1;
/* 游戏主循环 */
while (1) {
/* 画棋盘 */
printf("\n 1 2 3 <- 列号\n");
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == 1) {
printf(" X ");
} else if (board[row][col] == 2) {
printf(" O ");
} else {
printf(" ");
}
if (col < 2) {
printf("|");
}
}
printf("\n");
if (row < 2) {
printf("---+---+---\n");
}
}
/* 提示轮到谁 */
if (currentPlayer == 1) {
printf(">>> 轮到 [玩家1] (X) 落子\n");
} else {
printf(">>> 轮到 [玩家2] (O) 落子\n");
}
/* 游戏菜单:可以中途退出 */
printf("---------------------------------\n");
printf(" 游戏菜单\n");
printf(" 1. 继续游戏\n");
printf(" 2. 返回主菜单\n");
printf("请输入数字(1-2):");
scanf("%d", &gameChoice);
if (gameChoice == 2) {
printf("已返回主菜单。\n");
break;
}
/* 输入落子位置 */
printf("请输入落子位置(行号 列号,例如:1 1):");
scanf("%d %d", &posRow, &posCol);
board[posRow - 1][posCol - 1] = currentPlayer;
/* 判断当前玩家是否赢了 */
win = 0;
for (row = 0; row < 3 && win == 0; row = row + 1) {
if (board[row][0] == currentPlayer && board[row][1] == currentPlayer && board[row][2] == currentPlayer) {
win = 1;
}
}
for (col = 0; col < 3 && win == 0; col = col + 1) {
if (board[0][col] == currentPlayer && board[1][col] == currentPlayer && board[2][col] == currentPlayer) {
win = 1;
}
}
if (win == 0 && board[0][0] == currentPlayer && board[1][1] == currentPlayer && board[2][2] == currentPlayer) {
win = 1;
}
if (win == 0 && board[0][2] == currentPlayer && board[1][1] == currentPlayer && board[2][0] == currentPlayer) {
win = 1;
}
if (win == 1) {
printf("\n恭喜!玩家%d 获胜!\n", currentPlayer);
break; /* 本局结束,回到主菜单 */
}
/* 判断是否平局:棋盘下满了 */
full = 1;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == 0) {
full = 0;
}
}
}
if (full == 1) {
printf("\n棋盘下满,平局!\n");
break;
}
/* 换人 */
if (currentPlayer == 1) {
currentPlayer = 2;
} else {
currentPlayer = 1;
}
}
} else if (choice == 2) {
printf("再见!\n");
break;
} else {
printf("输入不合法,请重新输入!\n");
}
}
return 0;
}
运行效果:
好兄弟,我不知道你做到这里的感受是什么样的。如果你从头开始一步步做到了这里,我想我必须要夸你一下。去吃点东西犒劳自己吧。
吃完就回来,这篇文章还没有结束。我们还有很多地方可以优化。
2.8 第 8 步:把main拆分成多个函数
现在的main 函数又长又乱,读起来很费劲。我们用学过的函数把它拆开:每一个小功能,装进一个函数,main 只负责"按顺序调用"。
拆成这几个函数:
initBoard():清空棋盘showBoard():画棋盘showMainMenu():显示主菜单showGameMenu():显示游戏菜单checkWin(player):判断 player 是否赢了checkDraw():判断是否平局playGame():完整地玩一局
顺便把第 3 步欠的"防呆"补上:位置越界、或者已经有棋子,都要求重新输入。
#include <stdio.h>
/* 棋盘:0 表示空,1 表示 X,2 表示 O */
int board[3][3];
/* 当前轮到谁落子:1 或 2 */
int currentPlayer;
/* 清空棋盘 */
void initBoard(void)
{
int row, col;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
board[row][col] = 0;
}
}
}
/* 画棋盘 */
void showBoard(void)
{
int row, col;
printf("\n 1 2 3 <- 列号\n");
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == 1) {
printf(" X ");
} else if (board[row][col] == 2) {
printf(" O ");
} else {
printf(" ");
}
if (col < 2) {
printf("|");
}
}
printf("\n");
if (row < 2) {
printf("---+---+---\n");
}
}
printf("\n");
}
/* 显示主菜单 */
void showMainMenu(void)
{
printf("=================================\n");
printf(" 井 字 棋 -- 主菜单\n");
printf("=================================\n");
printf(" 1. 开始新游戏\n");
printf(" 2. 退出游戏\n");
printf("请输入数字(1-2):");
}
/* 显示游戏菜单 */
void showGameMenu(void)
{
printf("---------------------------------\n");
printf(" 游戏菜单\n");
printf(" 1. 继续游戏\n");
printf(" 2. 返回主菜单\n");
printf("请输入数字(1-2):");
}
/* 判断 player 是否赢了:赢了返回 1,否则返回 0 */
int checkWin(int player)
{
int i;
/* 检查每一行 */
for (i = 0; i < 3; i = i + 1) {
if (board[i][0] == player && board[i][1] == player && board[i][2] == player) {
return 1;
}
}
/* 检查每一列 */
for (i = 0; i < 3; i = i + 1) {
if (board[0][i] == player && board[1][i] == player && board[2][i] == player) {
return 1;
}
}
/* 检查两条对角线 */
if (board[0][0] == player && board[1][1] == player && board[2][2] == player) {
return 1;
}
if (board[0][2] == player && board[1][1] == player && board[2][0] == player) {
return 1;
}
return 0;
}
/* 判断是否平局:棋盘全部下满且没人赢 */
int checkDraw(void)
{
int row, col;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == 0) {
return 0; /* 还有空位,不是平局 */
}
}
}
return 1; /* 全部下满,平局 */
}
/* 读取一个 1~2 的数字 */
int readChoice(void)
{
int choice;
scanf("%d", &choice);
return choice;
}
/* 完整地玩一局 */
void playGame(void)
{
int posRow, posCol;
int gameChoice;
int finished;
initBoard();
currentPlayer = 1;
finished = 0;
while (finished == 0) {
showBoard();
/* 提示轮到谁 */
if (currentPlayer == 1) {
printf(">>> 轮到 [玩家1] (X) 落子\n");
} else {
printf(">>> 轮到 [玩家2] (O) 落子\n");
}
/* 游戏菜单:可以中途退出 */
showGameMenu();
gameChoice = readChoice();
if (gameChoice == 2) {
printf("已返回主菜单。\n\n");
return;
}
/* 输入落子位置:不合法就重新输入(防呆) */
printf("请输入落子位置(行号 列号,例如:1 1):");
scanf("%d %d", &posRow, &posCol);
while (posRow < 1 || posRow > 3 || posCol < 1 || posCol > 3 ||
board[posRow - 1][posCol - 1] != 0) {
printf("位置不合法,请重新输入(行号 列号):");
scanf("%d %d", &posRow, &posCol);
}
/* 落子 */
board[posRow - 1][posCol - 1] = currentPlayer;
/* 判断是否获胜 */
if (checkWin(currentPlayer) == 1) {
showBoard();
printf("恭喜!玩家%d 获胜!\n", currentPlayer);
finished = 1;
} else if (checkDraw() == 1) {
/* 没人赢而且棋盘下满,平局 */
showBoard();
printf("棋盘下满,平局!\n");
finished = 1;
} else {
/* 换人 */
if (currentPlayer == 1) {
currentPlayer = 2;
} else {
currentPlayer = 1;
}
}
}
printf("游戏结束,返回主菜单。\n\n");
}
/* 程序入口 */
int main(void)
{
int choice;
while (1) {
showMainMenu();
choice = readChoice();
if (choice == 1) {
playGame();
} else if (choice == 2) {
printf("再见!\n");
break;
} else {
printf("输入不合法,请重新输入!\n");
}
}
return 0;
}
运行效果和第 7 步一模一样,代码一下清晰了十倍,这就是函数的力量。
功能没变,但代码结构完全不一样了,我们管这个叫”重构“。
在左边的大纲视图中,可以看当前文件里有哪些函数和全局变量,点击即可在文档中跳转到对应位置。
在函数外边(不在任意一个函数里边)的变量叫全局变量。
2.9 第 9 步:用枚举代替魔法数字
现在代码里到处都是 0、1、2,它们是什么意思?全靠记。下面,让我们用枚举给它们起名字。
在程序最上面(#include 之后)加 3 个枚举:
/* 棋子的类型:空格、X、O */
typedef enum { CELL_EMPTY = 0, CELL_X = 1, CELL_O = 2 } CellType;
/* 主菜单的选择 */
typedef enum { MENU_NEWGAME = 1, MENU_EXIT = 2 } MainMenuChoice;
/* 游戏菜单的选择 */
typedef enum { GAME_CONTINUE = 1, GAME_MAIN = 2 } GameMenuChoice;
然后做下面的替换(意思一样,名字更清楚):
| 原变量 | 修改后变量 | 在哪里 |
|---|---|---|
int board[3][3] | CellType board[3][3] | 全局(在函数外边) |
int currentPlayer; | CellType currentPlayer; | 全局 |
board[row][col] = 0 | board[row][col] = CELL_EMPTY | initBoard |
== 1 | == CELL_X | showBoard |
== 2 | == CELL_O | showBoard |
== 0 | == CELL_EMPTY | checkDraw |
currentPlayer = 1 | currentPlayer = CELL_X | playGame |
== 1 / == 2(轮到的提示) | == CELL_X / else | playGame |
1 -> 2、2 -> 1(换人) | CELL_X -> CELL_O、CELL_O -> CELL_X | playGame |
gameChoice == 2 | gameChoice == GAME_MAIN | playGame |
choice == 1 | choice == MENU_NEWGAME | main |
choice == 2 | choice == MENU_EXIT | main |
顺便把 readChoice 升级一下,让它自己检查输入范围(main 里那句"输入不合法"的 else 就可以删掉了):
/* 读取一个 min 到 max 之间的数字,不合法就重新输入 */
int readChoice(int min, int max)
{
int choice;
do {
scanf("%d", &choice);
if (choice < min || choice > max) {
printf("输入不合法!请重新输入(%d-%d):", min, max);
}
} while (choice < min || choice > max);
return choice;
}
调用也要跟着改:
gameChoice = readChoice(GAME_CONTINUE, GAME_MAIN); /* 在 playGame 里 */
...
choice = readChoice(MENU_NEWGAME, MENU_EXIT); /* 在 main 里 */
运行效果:功能不变,但代码的可读性再次提升,看到 CELL_X 就知道是 X 棋子,看到 GAME_MAIN 就知道是"返回主菜单"。
2.10 第 10 步:用结构体组织数据(最终版)
还剩下几处"零散但关联"的数据:玩家的棋子和它的显示符号、落子的行和列。用我们学的结构体把它们绑在一起。
1. 加两个结构体(放在枚举下面):
/* 玩家信息:用的棋子 + 显示符号 */
typedef struct {
CellType piece; /* 棋子在棋盘上的值:CELL_X 或 CELL_O */
char mark; /* 显示用的符号:'X' 或 'O' */
} PlayerInfo;
/* 落子位置:行号 + 列号 */
typedef struct {
int row; /* 行号 0~2 */
int col; /* 列号 0~2 */
} Position;
2. 加一个结构体数组,存两位玩家(players[0] 是玩家 1,players[1] 是玩家 2):
PlayerInfo players[2];
3. 加两个小函数:初始化玩家、显示玩家信息。
/* 初始化两位玩家的信息 */
void initPlayers(void)
{
players[0].piece = CELL_X;
players[0].mark = 'X';
players[1].piece = CELL_O;
players[1].mark = 'O';
}
/* 用结构体数组里的信息显示两位玩家 */
void showPlayerInfo(void)
{
int i;
for (i = 0; i < 2; i = i + 1) {
printf("玩家%d:玩家%d (使用 %c)\n", i + 1, i + 1, players[i].mark);
}
printf("\n");
}
4. 把"输入位置"抽成一个函数,返回一个 Position(防呆逻辑也一起搬进去):
/* 让当前玩家输入落子位置,返回一个 Position 结构体。
* 位置越界或者已经有棋子,就重新输入。 */
Position inputPosition(void)
{
int row, col;
int ok;
Position pos;
do {
printf("请输入落子位置(行号 列号,例如:1 1):");
scanf("%d %d", &row, &col);
ok = 1;
if (row < 1 || row > 3 || col < 1 || col > 3) {
printf("位置超出棋盘范围,请重新输入!\n");
ok = 0;
} else if (board[row - 1][col - 1] != CELL_EMPTY) {
printf("这个位置已经有棋子了,请重新输入!\n");
ok = 0;
}
} while (ok == 0);
pos.row = row - 1;
pos.col = col - 1;
return pos;
}
5. 把"显示结果"也抽成一个函数,赢家和平局都归它管:
/* 显示比赛结果:winner 是获胜的棋子,传 CELL_EMPTY 表示平局 */
void showResult(CellType winner)
{
printf("=================================\n");
if (winner == CELL_EMPTY) {
printf(" 棋盘已满!\n");
printf("=================================\n");
printf("平局!\n");
} else {
printf(" 恭喜!\n");
printf("=================================\n");
printf("玩家%d (%c) 获胜!\n", winner, players[winner - 1].mark);
}
}
6. 最后改 playGame:用 Position 接收位置、开局调用 initPlayers/showPlayerInfo、赢了调用 showResult。
void playGame(void)
{
Position pos;
int moveCount;
int finished;
initBoard();
initPlayers();
currentPlayer = CELL_X;
moveCount = 0;
finished = 0;
printf("=================================\n");
printf(" 井 字 棋 游 戏\n");
printf("=================================\n");
showPlayerInfo();
while (finished == 0) {
showBoard();
/* 提示轮到谁落子 */
printf(">>> 轮到 [玩家%d] (%c) 落子\n", currentPlayer, players[currentPlayer - 1].mark);
/* 游戏菜单:可以中途退出 */
showGameMenu();
if (readChoice(GAME_CONTINUE, GAME_MAIN) == GAME_MAIN) {
printf("\n已返回主菜单。\n\n");
return;
}
/* 落子 */
pos = inputPosition();
board[pos.row][pos.col] = currentPlayer;
moveCount = moveCount + 1;
/* 判断是否获胜 */
if (checkWin(currentPlayer) == 1) {
showBoard();
showResult(currentPlayer);
finished = 1;
} else if (checkDraw() == 1) {
/* 没人赢而且棋盘下满了,平局 */
showBoard();
showResult(CELL_EMPTY);
finished = 1;
} else {
/* 都没达成,换下一位玩家 */
if (currentPlayer == CELL_X) {
currentPlayer = CELL_O;
} else {
currentPlayer = CELL_X;
}
}
}
printf("游戏结束,返回主菜单。\n\n");
}
结束了,终于结束了。我想,你大概已经很累了吧。
如果你中途没有编译过,一直敲到了这里,最后编译时出现了语法错误的话,也先别改了;把第三部分的参考代码复制一份跑通,分享给自己的朋友,然后去吃点好吃的吧。
每一步的代码在code文件夹里都有记录,可以用trae打开它,然后问ai问题。
函数、枚举、结构体的作用在这几步体现的淋漓尽致,第七步出来的代码还是一坨屎山,这个时候感觉B格都变高了。我没骗你吧,教的东西都是有用的。
2.11 战后总结
ok,回头看看我们做了什么:
- 第 1 步:一个只能选菜单的程序
- 第 2 步:能看到棋盘
- 第 3 步:能落子
- 第 4 步:两个人轮流下
- 第 5 步:会判断输赢
- 第 6 步:会判断平局
- 第 7 步:能中途退出,功能齐了
- 第 8 步:用函数把代码理清
- 第 9 步:用枚举给数字起名字
- 第 10 步:用结构体组织数据,最终版
每一步都能编译、能运行、能看到效果——从"一个菜单"到"一个完整的游戏",没有哪一步是跳跃的。这就是把大问题拆成小问题的能力:先跑起来,再变好。
这种"拆解复杂问题"的能力,比任何语法都重要。语法会忘会变,而做软件的思维是不会变的。
3 参考代码
/* =====================================================
* 2-5 项目-井字棋
* 一个完整的井字棋小游戏:
* 主菜单 -> 开始游戏 -> 轮流落子 -> 判断胜负/平局 -> 返回主菜单
* 用到的知识点:二维数组、函数、结构体、结构体数组、enum 枚举、typedef
* ===================================================== */
#include <stdio.h>
/* 棋子的类型:空格、X、O */
typedef enum { CELL_EMPTY = 0, CELL_X = 1, CELL_O = 2 } CellType;
/* 主菜单的选择 */
typedef enum { MENU_NEWGAME = 1, MENU_EXIT = 2 } MainMenuChoice;
/* 游戏菜单的选择 */
typedef enum { GAME_CONTINUE = 1, GAME_MAIN = 2 } GameMenuChoice;
/* 玩家信息:用的棋子 + 显示符号 */
typedef struct {
CellType piece; /* 棋子在棋盘上的值:CELL_X 或 CELL_O */
char mark; /* 显示用的符号:'X' 或 'O' */
} PlayerInfo;
/* 落子位置:行号 + 列号 */
typedef struct {
int row; /* 行号 0~2 */
int col; /* 列号 0~2 */
} Position;
/* 棋盘:board[行][列],取值是上面的 CellType */
int board[3][3];
/* 两位玩家:players[0] 是玩家1 (X),players[1] 是玩家2 (O) */
PlayerInfo players[2];
/* 当前轮到谁落子:CELL_X 或 CELL_O */
CellType currentPlayer;
/* 把棋盘全部清空 */
void initBoard(void)
{
int row, col;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
board[row][col] = CELL_EMPTY;
}
}
}
/* 初始化两位玩家的信息 */
void initPlayers(void)
{
players[0].piece = CELL_X;
players[0].mark = 'X';
players[1].piece = CELL_O;
players[1].mark = 'O';
}
/* 显示主菜单 */
void showMainMenu(void)
{
printf("=================================\n");
printf(" 井 字 棋 -- 主菜单\n");
printf("=================================\n");
printf(" 1. 开始新游戏\n");
printf(" 2. 退出游戏\n");
printf("请输入数字(1-2):");
}
/* 显示游戏菜单 */
void showGameMenu(void)
{
printf("---------------------------------\n");
printf(" 游戏菜单\n");
printf(" 1. 继续游戏\n");
printf(" 2. 返回主菜单\n");
printf("请输入数字(1-2):");
}
/* 读取一个 min 到 max 之间的数字,不合法就重新输入 */
int readChoice(int min, int max)
{
int choice;
do {
scanf("%d", &choice);
if (choice < min || choice > max) {
printf("输入不合法!请重新输入(%d-%d):", min, max);
}
} while (choice < min || choice > max);
return choice;
}
/* 用结构体数组里的信息显示两位玩家 */
void showPlayerInfo(void)
{
int i;
for (i = 0; i < 2; i = i + 1) {
printf("玩家%d:玩家%d (使用 %c)\n", i + 1, i + 1, players[i].mark);
}
printf("\n");
}
/* 显示棋盘 */
void showBoard(void)
{
int row, col;
printf("\n 1 2 3 <- 列号\n");
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == CELL_X) {
printf(" X ");
} else if (board[row][col] == CELL_O) {
printf(" O ");
} else {
printf(" ");
}
if (col < 2) {
printf("|");
}
}
printf("\n");
if (row < 2) {
printf("---+---+---\n");
}
}
printf("\n");
}
/* 判断指定玩家是否赢了,赢了返回 1,否则返回 0 */
int checkWin(int player)
{
int i;
/* 检查每一行 */
for (i = 0; i < 3; i = i + 1) {
if (board[i][0] == player && board[i][1] == player && board[i][2] == player) {
return 1;
}
}
/* 检查每一列 */
for (i = 0; i < 3; i = i + 1) {
if (board[0][i] == player && board[1][i] == player && board[2][i] == player) {
return 1;
}
}
/* 检查两条对角线 */
if (board[0][0] == player && board[1][1] == player && board[2][2] == player) {
return 1;
}
if (board[0][2] == player && board[1][1] == player && board[2][0] == player) {
return 1;
}
return 0;
}
/* 判断是否平局:棋盘全部下满且没有人赢 */
int checkDraw(void)
{
int row, col;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == CELL_EMPTY) {
return 0; /* 还有空位置,不算平局 */
}
}
}
return 1; /* 全部下满,平局 */
}
/* 让当前玩家输入落子位置,返回一个 Position 结构体。
* 位置越界或者已经有棋子,就重新输入。 */
Position inputPosition(void)
{
int row, col;
int ok;
Position pos;
do {
printf("请输入落子位置(行号 列号,例如:1 1):");
scanf("%d %d", &row, &col);
ok = 1;
if (row < 1 || row > 3 || col < 1 || col > 3) {
printf("位置超出棋盘范围,请重新输入!\n");
ok = 0;
} else if (board[row - 1][col - 1] != CELL_EMPTY) {
printf("这个位置已经有棋子了,请重新输入!\n");
ok = 0;
}
} while (ok == 0);
pos.row = row - 1;
pos.col = col - 1;
return pos;
}
/* 显示比赛结果:winner 是获胜的棋子,传 CELL_EMPTY 表示平局 */
void showResult(CellType winner)
{
printf("=================================\n");
if (winner == CELL_EMPTY) {
printf(" 棋盘已满!\n");
printf("=================================\n");
printf("平局!\n");
} else {
printf(" 恭喜!\n");
printf("=================================\n");
printf("玩家%d (%c) 获胜!\n", winner, players[winner - 1].mark);
}
}
/* 完整地玩一局游戏 */
void playGame(void)
{
Position pos;
int moveCount;
int finished;
initBoard();
initPlayers();
currentPlayer = CELL_X;
moveCount = 0;
finished = 0;
printf("=================================\n");
printf(" 井 字 棋 游 戏\n");
printf("=================================\n");
showPlayerInfo();
while (finished == 0) {
showBoard();
/* 提示轮到谁落子 */
printf(">>> 轮到 [玩家%d] (%c) 落子\n", currentPlayer, players[currentPlayer - 1].mark);
/* 游戏菜单:可以中途退出 */
showGameMenu();
if (readChoice(GAME_CONTINUE, GAME_MAIN) == GAME_MAIN) {
printf("\n已返回主菜单。\n\n");
return;
}
/* 落子 */
pos = inputPosition();
board[pos.row][pos.col] = currentPlayer;
moveCount = moveCount + 1;
/* 判断是否获胜 */
if (checkWin(currentPlayer) == 1) {
showBoard();
showResult(currentPlayer);
finished = 1;
} else if (checkDraw() == 1) {
/* 没人赢而且棋盘下满了,平局 */
showBoard();
showResult(CELL_EMPTY);
finished = 1;
} else {
/* 都没达成,换下一位玩家 */
if (currentPlayer == CELL_X) {
currentPlayer = CELL_O;
} else {
currentPlayer = CELL_X;
}
}
}
printf("\n游戏结束,返回主菜单。\n\n");
}
/* 程序入口 */
int main(void)
{
int choice;
while (1) {
showMainMenu();
choice = readChoice(MENU_NEWGAME, MENU_EXIT);
if (choice == MENU_EXIT) {
printf("\n再见!\n");
break;
}
printf("\n");
playGame();
}
return 0;
}