2-5 井字棋

148 阅读23分钟

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 落实需求:一步一步做出来

先记住一个原则:不要一上来就写最终版,一步一步来。

下面我们把需求拆成很多小步,一次只加一个功能。每做完一步,都立刻编译运行,亲眼看到效果,再进入下一步。每走一步,你的程序都比上一步更强一点——这就是正反馈。

第 13 步、第 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
再见!

5-1 井字棋第一步

虽然还不能下棋,但你已经有一个"能选菜单、能退出"的程序了。

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    <- 列号
   |   |
---+---+---
   |   |
---+---+---
   |   |

5-2 井字棋第二步

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 步我们再来"防呆",处理比如说位置越界、下到已经有棋子的地方等边界情况。

5-3 井字棋第三步

能在棋盘上落子了! 现在的问题是只能放 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。

5-4 井字棋第四步

**两个人能轮流下棋了!**下一步我们加上判断输赢。

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 获胜!",然后回到主菜单。

5-5 井字棋第五步1

5-6 井字棋第五步2

接下来让我们处理平局的情况。

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 个格子填满还没人赢,显示"棋盘下满,平局!"。

5-7 井字棋第六步1

5-7 井字棋第六步2

5-8 井字棋第六步3

至此,一局棋有了完整的结局:要么有人赢,要么平局。

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;
}

运行效果:

5-9 井字棋第七步

好兄弟,我不知道你做到这里的感受是什么样的。如果你从头开始一步步做到了这里,我想我必须要夸你一下。去吃点东西犒劳自己吧。

吃完就回来,这篇文章还没有结束。我们还有很多地方可以优化。

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 步一模一样,代码一下清晰了十倍,这就是函数的力量。

功能没变,但代码结构完全不一样了,我们管这个叫”重构“。

5-10 井字棋第八步

在左边的大纲视图中,可以看当前文件里有哪些函数和全局变量,点击即可在文档中跳转到对应位置。

在函数外边(不在任意一个函数里边)的变量叫全局变量。

5-11 大纲视图

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] = 0board[row][col] = CELL_EMPTYinitBoard
== 1== CELL_XshowBoard
== 2== CELL_OshowBoard
== 0== CELL_EMPTYcheckDraw
currentPlayer = 1currentPlayer = CELL_XplayGame
== 1 / == 2(轮到的提示)== CELL_X / elseplayGame
1 -> 2、2 -> 1(换人)CELL_X -> CELL_O、CELL_O -> CELL_XplayGame
gameChoice == 2gameChoice == GAME_MAINplayGame
choice == 1choice == MENU_NEWGAMEmain
choice == 2choice == MENU_EXITmain

顺便把 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 就知道是"返回主菜单"。

5-12 井字棋第九步

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");
}

5-13 井字棋第十步

结束了,终于结束了。我想,你大概已经很累了吧。

如果你中途没有编译过,一直敲到了这里,最后编译时出现了语法错误的话,也先别改了;把第三部分的参考代码复制一份跑通,分享给自己的朋友,然后去吃点好吃的吧。

每一步的代码在code文件夹里都有记录,可以用trae打开它,然后问ai问题。

函数、枚举、结构体的作用在这几步体现的淋漓尽致,第七步出来的代码还是一坨屎山,这个时候感觉B格都变高了。我没骗你吧,教的东西都是有用的。

2.11 战后总结

ok,回头看看我们做了什么:

  1. 第 1 步:一个只能选菜单的程序
  2. 第 2 步:能看到棋盘
  3. 第 3 步:能落子
  4. 第 4 步:两个人轮流下
  5. 第 5 步:会判断输赢
  6. 第 6 步:会判断平局
  7. 第 7 步:能中途退出,功能齐了
  8. 第 8 步:用函数把代码理清
  9. 第 9 步:用枚举给数字起名字
  10. 第 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;
}