用C语言实现井字棋(上)

阅读 171
收藏 7
2018-11-06
原文链接:zhuanlan.zhihu.com

Milo Yip:用C++编写游戏容易吗?有什么开源的小游戏吗?能分享一下吗? 这个答案中,我提到学习游戏编程可从回合制游戏开始,例如井字棋。

考虑到一些初学者的学习需求,我就写一个井字棋的教程吧。上篇实现二人游戏,下篇加上「人工智能」后约有 100 行 C 语言代码。


1. 游戏状态的表示

首先,我认为表示方法(representation)是编程中应最先要考虑的事情。对于回合制游戏,我们需要存储一个回合中的游戏状态(game state)。

以下用一个结构体表示井字棋一个回合中的状态,并加入函数作初始化:

typedef struct {
    int board[3][3];    // -1 = empty, 0 = O, 1 = X
    int turn;           // O first
} state;

void init(state* s) {
    int i, j;
    for (j = 0; j < 3; j++)
        for (i = 0; i < 3; i++)
            s->board[j][i] = -1;
    s->turn = 0;
}

以上用二维数组存储棋盘(board)是其中一种表示方式,另一种方式则是记录每个回合下棋子的位置。我们采用前者是因为它较容易实现胜负判定。有些回合制游戏可能使用冗余的表示方式,以方便实现各种规则。

而使用结构体而不是直接用全局变量,可带来一些优点,例如增强可读性及内聚性。


2. 显示游戏状态

编写游戏时,我们通常希望先显示游戏状态,之后才加入其他规则,因为这样可以方便测试。

我希望用这样的文本显示游戏状态,当空置时写上位置编号(1-9),以方便玩家输入下棋位置:

1 | 2 | 3 
---+---+---
 4 | 5 | 6 
---+---+---
 7 | 8 | 9

简单直白地编写代码的话:

void display(const state* s) {
    int i, j;
    for (j = 0; j < 3; j++) {
        for (i = 0; i < 3; i++) {
            switch (s->board[j][i]) {
                case -1: printf(" %d ", j * 3 + i + 1); break;
                case  0: printf(" O "); break;
                case  1: printf(" X "); break;
            }
            if (i < 2)
                printf("|");
            else
                printf("\n");
        }
        if (j < 2)
            printf("---+---+---\n");
        else
            printf("\n");
    }
}

由于 display() 只读而不改变游戏状态,所以其参数类型为 const state*

我们稍压缩一下代码:

void display(const state* s) {
    int i, j;
    for (j = 0; j < 3; printf(++j < 3 ? "---+---+---\n" : "\n"))
        for (i = 0; i < 3; putchar("||\n"[i++]))
            printf(" %c ", s->board[j][i] == -1 ? '1' + j * 3 + i : "OX"[s->board[j][i]]);
}

我们可以加入 main() 函数去显示初始化的状态:

int main() {
    state s;
    init(&s);
    display(&s);
}

此阶段的完整代码位于 tictactoe0.c


3. 实现下棋

然后,我们加入第一个游戏规则,就是下棋:

int move(state* s, int i, int j) {
    if (s->board[j][i] != -1)
        return 0;
    s->board[j][i] = s->turn++ % 2;
    return 1;
}

函数内做了一个合法性判断,如果该位置已有棋子,则返回 0 表示失败。成功的话,在偶数回合填入 0,表示 O;奇数回合填入 1,表示 X;然后都把回合加一。

更改 main() 简单测试:

int main() {
    state s;
    init(&s);
    display(&s);
    move(&s, 1, 1);
    display(&s);
    move(&s, 0, 1);
    display(&s);
}

输出:

此阶段的完整代码位于 tictactoe1.c


4. 处理输入

在每一回合中,提示当前玩家(O 或 X),并让玩家输入一个下棋位置(1-9),如果位置不合法,则重新输入:

void human(state* s) {
    char c;
    do {
        printf("%c: ", "OX"[s->turn % 2]);
        c = getchar();
        while (getchar() != '\n');
        printf("\n");
    } while (c < '1' || c > '9' || !move(s, (c - '1') % 3, (c - '1') / 3));
}

在标准输入中,要到回车键才能处理输入,所以这里我们读了第一个输入字符后,就忽略其他字符直到读到换行符。我们把表示位置的字符转换成二维数组索引。

然后,就可以修改 main() 实现二人下棋的流程:

int main() {
    state s;
    init(&s);
    display(&s);
    while (s.turn < 9) {
        human(&s);
        display(&s);
    } 
}

此阶段的完整代码位于 tictactoe2.c


5. 胜负判定

众所周知,井字棋的胜利条件,是有三个棋子在横线、直线或斜线连成一线。我们实现一个 evaluate() 函数去评估棋局的状态,如果 O 胜出则返回 1,X 胜出则返回 -1,不分胜负则返回 0:

#define CHECK(j1, i1, j2, i2, j3, i3) \
    if (s->board[j1][i1] != -1 && s->board[j1][i1] == s->board[j2][i2] && s->board[j1][i1] == s->board[j3][i3]) \
        return s->board[j1][i1] == 0 ? 1 : -1;

int evaluate(const state* s) {
    int i;
    for (i = 0; i < 3; i++) {
        CHECK(i, 0, i, 1, i, 2);    // horizontal
        CHECK(0, i, 1, i, 2, i);    // vertical
    }
    CHECK(0, 0, 1, 1, 2, 2);        // diagonal
    CHECK(0, 2, 1, 1, 2, 0);        // diagonal
    return 0;
}

上面的代码使用了一个宏 CHECK() 去检测三个位置是否都为相同的棋子,如是则直接返回胜方。

最后,我们在 main() 中,待每次下棋及显示状态后, 判定是否出现胜方,如果到达第 9 个回合(回合从 0 开始),则判定是平局(draw):

int main() {
    state s;
    init(&s);
    display(&s);
    while (s.turn < 9) {
        human(&s);
        display(&s);
        switch (evaluate(&s)) {
            case  1: printf("O win\n"); return 0;
            case -1: printf("X win\n"); return 0;
        }
    } 
    printf("Draw\n");
}

此阶段的完整代码位于 tictactoe3.c


6. 总结

本篇实现了二人井字棋,它是一个简单的回合制游戏。我们先选择了游戏的状态表示方式(state结构体及init()函数),然后把状态以文本形式显示(display()函数),加入每回合下棋规则(move()函数),以及人类玩家的输入处理(human()函数),并作胜负判定(evaluate()函数),最后在main()里则实现了按回合的循环及输出胜负结果。

虽然这个游戏本身以及 60 行的示例代码都很简单,但这个框架可以用于实现其他(更复杂的)回合制游戏。实时游戏(如动作游戏)的主要区别,其实也只在于把输入部分做成非阻塞的函数,而该循环则称为游戏循环(game loop)。

下篇将加入不败的「人工智能」。

评论