五子棋游戏-C语言实现

五子棋游戏

这里实现的是 3*3 的棋盘,后续扩展到 n*n 的棋盘。

五子棋游戏都很熟悉了,其规则很简单,就是哪个玩家先满足多少个棋子在同一条线上则为赢家。

本文源码链接: five_in_a_row_chess.c

试玩一:
img

试玩二:
img

棋盘

第一个就是要制造输出一个棋盘,我们这里使用的是 3*3 即下面的布局:

img

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <stdio.h>
#include <stdbool.h>
#include <math.h>


// 棋盘输出格式
char simbols[7][13] = {
{ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-' }, // 0
// j: 2, 6, 10 | 1 2 3
{ '|', ' ', '0', ' ', '|', ' ', '0', ' ', '|', ' ', '0', ' ', '|' }, // 1
{ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-' }, // 2
// j: 2, 6, 10 | 1 2 3
{ '|', ' ', '0', ' ', '|', ' ', '0', ' ', '|', ' ', '0', ' ', '|' }, // 3
{ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-' }, // 4
// j: 2, 6, 10 | 1 2 3
{ '|', ' ', '0', ' ', '|', ' ', '0', ' ', '|', ' ', '0', ' ', '|' }, // 5
{ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-' }, // 6
};

// 检测当前位置是否已有棋子
_Bool located = false;
// 棋盘大小 n * n
int size = 3;

void printTable(int loc, char flag) {

located = false;

for (unsigned int i = 0; i < 7; i++) {
for (unsigned int j = 0; j < 13; j++) {
// 棋子所在的行和列
if (i % 2 == 1) {
if ((j - 2) % 4 == 0) {
// 能被 4 整除,即位置在 2 - 6 - 10 上
// j - 2 得到 0 - 4 - 8
// (j - 2) % 4 得到 0 - 1 - 2
// 这样我们便可以让 i - j 和 loc 发生联系
// 并根据 loc 传入的位置通过 i - j 计算出放置的位置 k 的标识值

// 把 1 - 3 - 5 奇数变成自然数
// 记录当前行
int r = (i + 1) / 2;

// j - 2 得到 0 - 4 - 8
// 除以 4 得到 0 - 1 - 2
// 加上 (r - 1) * size 当前行的基数即行首数字的索引(在实际棋盘上)
// 最后加上 1 就得到当前位置的值(因为棋子是从 1 开始的,即 1 ~ 9)
// 计算出来的位置,结果要与 loc 一致
int k = ((j - 2) / 4) + (r - 1) * size + 1; // -> 0 1 2

// k != i 排除末尾数延伸到第二行第一个
// 且行数要一致
if (loc == k && r == ceil((float)loc / size)) {
// DONE 已经落子的地方不能再下了
char c = simbols[i][j];
if (c == 'X' || c == 'Y') {
located = true;
} else {
// 最后得到 loc 应该放置的位置
simbols[i][j] = flag;
}
}
}
}
printf("%c", simbols[i][j]);
}
printf("\n");
}
}

int main()
{

printTable(1, '0');
return 0;
}

printTable 的代码核心在两个 for 循环里面,因为棋盘里面除了有效的棋子位置(0 的位置),其他的是为了

组织整个棋盘用的。

然而玩家在玩游戏的时候是通过输入棋盘中所有有效棋子位置的数字来落子的,即可输入的允许范围为 1~9 的数字。

为了将 1~9 的数字与实际棋盘上的数字位置对应,并发生联系,我们需要如下计算:

  • 计算出实际棋盘中所有棋子的位置(行和列)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 1. 第一步 我们观察得出棋盘的棋子的行数均为奇数行
    // 因此通过 i % 2 == 1 限定行
    if (i % 2 == 1) {
    // 2. 第二步,计算出棋子列,这一步比较麻烦一点
    // 因为列上包含了 `|` 和 `空格` 非有效棋子位置
    // 但我们会发现棋子的列数有如下规律:
    // 2 -> 6 -> 10 减去 2 之后
    // 0 -> 4 -> 8 得出为 4 的倍数
    if ((j - 2) % 4 == 0) {
    // 到这里我们变将所有有效棋子位置计算出来了
    }
    }
  • 将行数(奇数)转成自然数

    为了方便计算和用户所感知的棋盘对应上,我们需要将上面计算出来的行(奇数)转成自然数。

    即: int r = (i + 1) / 2;

  • 计算出玩家当前输入的位置

    其实我们是知道玩家输入的位置数字的,这里还要通过计算,是因为我们需要使用用户输入的位置和计算出来的进行比较,

    如果计算出来的一致,则放置棋子标识。

    因为棋盘本身是不知道的,所以需要计算,即下面的 k 的值。

    int k = ((j - 2) / 4) + (r - 1) * size + 1; // -> 0 1 2

    k 的值计算方式:

    1. ( j - 2 ) / 4 这个其实就是 if 里面的列的判断条件,得出循环当前的有效棋子列数
    2. (r - 1) * size 是需要计算当前循环的行,棋子所在的位置
    3. 最后 + 1 是因为给玩家的输入棋盘位置是从 1 开始的,因此需要加 1

    经过上面三个步骤,我们就得到了每次循环的时候 rowcol 两个条件下实际棋盘的位置和用户输入的是否一致。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 棋子所在的行和列
if (i % 2 == 1) {
if ((j - 2) % 4 == 0) {
// 记录当前行
int r = (i + 1) / 2;

int k = ((j - 2) / 4) + (r - 1) * size + 1; // -> 0 1 2

// k != i 排除末尾数延伸到第二行第一个
// 且行数要一致
if (loc == k && r == ceil((float)loc / size)) {
// DONE 已经落子的地方不能再下了
char c = simbols[i][j];
if (c == 'X' || c == 'Y') {
located = true;
} else {
// 最后得到 loc 应该放置的位置
simbols[i][j] = flag;
}
}
}
}
printf("%c", simbols[i][j]);

玩家落子

这里我们使用通过输入数字来模拟玩家落子。

  1. while 循环一直等待玩家输入
  2. player 记录当前玩家(玩家一用 X ,玩家二用 Y 记录)
  3. steps 记录当前所有玩家走过的步骤数,并且通过 steps 来判断是哪个玩家
  4. locscanf 用来接收玩家输入的棋子位置
  5. printTable 并且在每次有效的输入之后都打印出最新的棋盘
  6. next_char 检测下一个位置上的玩家标识是否和当前的一致,如果一致则记录一次 total
  7. total 最后满足条件的次数大于等于了 =
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

void play() {

int loc, who;
unsigned char player;

do {
who = steps % 2 == 0 ? 1 : 2;
player = who == 1 ? 'X' : 'Y';
printf(">> round player %d: ", who);
scanf("%d", &loc);

// TODO
steps++;
printf("total steps: %d\n", steps);
// 打印棋盘
printTable(loc, player);
} while(true);
}

到这里我们已经完成了,根据玩家的输入位置更新棋盘:

img

检测玩家输入的合法性

因为我们是通过玩家输入的数字来模拟落子的,因此这里需要做一些输入值的合法性判断。

3*3 的棋盘上我们允许输入的范围是 1~9 的数字。

因此在 play 函数上修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

void play() {

int loc, who;
unsigned char player;

do {
who = steps % 2 == 0 ? 1 : 2;
player = who == 1 ? 'X' : 'Y';
printf(">> round player %d: ", who);
scanf("%d", &loc);

// 修改点 1: 玩家输入合法性判断
if (loc < 1 || loc > 9) {
warn(1);
continue;
}

steps++;
printf("total steps: %d\n", steps);
// 打印棋盘
printTable(loc, player);
} while(true);
}

warn 警告函数,提示玩家落子非法,重新输入

1
2
3
4
5
6
7
8
9
10
void warn(unsigned int type) {
if (located) {
// 还原步骤
steps--;
printf("\n\n警告:当前位置已落子,请重新落子!!\n\n");
} else if (type == 1) {
// 输入超出棋盘
printf("\n\n警告:输入位置已超出棋盘范围,请重新落子!!\n\n");
}
}

warn 里面目前有两个判断标准:

  1. located 表示重复落子,即当前落子位置上已经有棋子了,不能再落子
  2. type = 1= 表示非法落子范围,即我们当前所需要的。

输赢的检测

这里输赢的检测也是该游戏程序的重点环节。

根据观察我们找一个有参考价值的棋子位置,即最中间的那个棋子位置(5 的位置),该位置上下左右,

左上,左下,右上,右下都有棋子,理论上来说每个棋子都需要检测这些位置。

检测函数 check_if_win

  1. direction 一个枚举类型,标识是哪个方向上的判断
  2. rowcol 当前落子位置的行和列数
  3. sign 当前玩家标识
  4. 函数内使用 switch...caseenum Directions 类型去处理 rowcol 的计算结果
  5. rowcol 计算出来的数字合法性
  6. win_limits 为多少个棋子连在一起算赢
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

// 检查当前落子的左上角
_Bool check_if_win(enum Directions direction, int row, int col, char sign) {

unsigned char next_char = 0;
unsigned total = 1;

while (true) {

switch (direction) {
case D_CORNER_LEFT_UP:
row--;
col--;
break;
case D_CORNER_RIGHT_UP:
row--;
col++;
break;
case D_CORNER_LEFT_DOWN:
col--;
row++;
break;
case D_CORNER_RIGHT_DOWN:
col++;
row--;
break;
case D_HORIZONTAL_LEFT:
col--;
break;
case D_HORIZONTAL_RIGHT:
col++;
break;
case D_VERTICAL_UP:
row--;
break;
case D_VERTICAL_DOWN:
row++;
break;
default:
break;
}

if (row >= size || col >= size || col < 0 || row < 0) {
return false;
}

// 左上角棋子
next_char = board[row][col];
if (next_char == sign) {
total++;
}

if (total >= win_limit) {
game_over(sign);
}
}

return false;
}

声明的枚举类型 enum Directions

1
2
3
4
5
6
7
8
9
10
11
// 当前棋子各个方向的声明
enum Directions {
D_CORNER_LEFT_UP,
D_CORNER_RIGHT_UP,
D_CORNER_LEFT_DOWN,
D_CORNER_RIGHT_DOWN,
D_HORIZONTAL_LEFT,
D_HORIZONTAL_RIGHT,
D_VERTICAL_UP,
D_VERTICAL_DOWN
};

游戏结束函数 gave_over

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void game_over(unsigned char winner) {
char* player = NULL;
if (winner == 'X') {
player = "Player 1";
} else if (winner == 'Y') {
player = "Player 2";
} else {
player = "Nobody";
}

printf("\n------ Gave Over ! ------\n");
printf(" Winner %s", player);
printf("\n------ Gave Over ! ------\n");
exit(0);
}

有结果了, exit 退出程序。

这里我们需要用到一个新的数组 board 用来存储玩家已落子的棋盘内容

1
2
3
4
5
6
7
8
9
void update_board(int num, char sign) {
unsigned int row = (num - 1) / size;
unsigned int col = (num - 1) % size;

board[row][col] = sign;

// 检测输赢
/* win(row, col, sign); */
}

update_board 添加到 printTable

1
2
3
4
5
6
7
8
9
10
11
12
if (loc == k && r == ceil((float)loc / size)) {
// DONE 已经落子的地方不能再下了
char c = simbols[i][j];
if (c == 'X' || c == 'Y') {
located = true;
} else {
// 最后得到 loc 应该放置的位置
simbols[i][j] = flag;
// 修改点 1:更新棋盘缓存
update_board(loc, flag);
}
}

完整代码

five_in_a_row_chess.c 完整代码

效果试玩

img

动态输出棋盘

之前使用的是固定的 simbols 棋盘数组,这种方式对于扩展到 n*n 的棋盘非常不方便。

现在我们为方便扩展,这里增加一个函数 init_board 用来初始化 simbols 棋盘数组。

这样我们将来便可以根据数组的大小(如现在初始化的 713 )来自由控制输出的棋盘大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

void init_board() {
for (unsigned int i = 0; i < 7; i++) {
for (unsigned int j = 0; j < 13; j++) {
if (i % 2 == 0) {
// 偶数行
simbols[i][j] = '-';
} else {
// 奇数行
if (j % 4 == 0) {
// 竖线位置
simbols[i][j] = '|';
} else if ((j + 1) % 4 == 0 || (j - 1) % 4 == 0) {
// 空格位置,棋子左右前后的位置
simbols[i][j] = ' ';
} else if ((j - 2) % 4 == 0) {
// 棋子位置
simbols[i][j] = '0';
}
}
}
}

printTable(1, '0');
}

使用两个 forsimbols 数组进行遍历,其中主要有几个 if...else if 决定了棋盘上的哪些位置

需要填充哪些内容。

比如:

  • i % 2 = 0= 这个表示是偶数行,这些行主要是分割行用,全部填充的是 - 横线;
  • 其余的是奇数行,奇数行有三个部分:竖线、空格、棋子

    从观察和分析可以知道:

    竖线位置规律是: 0, 4, 8, ...

    棋子位置: 2, 6, 10, ...

    棋子左位置: 1, 5, 9, ...

    棋子右位置: 3, 7, 11, ...

    这些数字都是有一定规律的,即通过减去一定的数字之后都可以是 4 的整数倍。

    • j % 4 = 0= 位置即竖线位置(0, 4, 8, …)
    • (j + 1) % 4 = 0= 和 (j - 1) % 4 = 0= 为棋子位置的前后空格位置
    • (j - 2) % 4 = 0= 为棋子位置

因此就可以得出上面的代码,来动态输出棋盘。

更大棋盘(n>3)

在上一节将棋盘 simbols 修改成了动态的棋盘了,当然这也是为了这一节的更大棋盘做的准备工作。

分析棋盘

要实现更大的棋盘,需要搞明白下面的这些数字的作用和关联。

  1. 棋盘数组 simbols 的行和列(7, 13)

  2. 棋子数组 board 的行和列(3, 3)

  3. 棋盘的 size 大小(3)

sizeboard 的这些数字很好理解,其实就可以理解为 board 的二维数组大小就是 size 的值,

因为它就是用来缓存当前棋盘上已经落的棋子。

simbols 是棋盘,那棋子是在这个棋盘上的,必然也有一定的关联:

行的关联不用说很简单 3 * 2 + 1size2 倍加 1 ,这的 1 就是多出的最后一行。

列的关联也可以和行的计算一样: 3 * 4 + 1 即从 | 开始计算到下一个 | 竖线之前占了几个,最后一列 +1

最后我们得出结论:只要确定了 size 就能知道 boardsimbols 大小。

改造实现更大棋盘

C 中是不允许在文件作用域中声明数组的时候直接使用变量的,否则会报错:

➜ cc five_in_a_row_chess.c -o a.out
five_in_a_row_chess.c:36:6: error: variable length array declaration not allowed at file scope
char board[size][3] = { 0 };

这个时候就需要考虑用到 malloc 去动态分配内存空间。

给棋盘和棋子数组分配空间(simbols, board)

首先得声明两个 char ** 指针的变量,表示二维数组:

1
2
3
4
// 棋盘落子存储
char **board = 0;
// 棋盘输出格式
char **simbols = 0;

完事了,我们将分配内存的工作放在初始化函数内(init_board):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

void init_board() {
// 在初始化之前先根据 `size` 分配内存
board = (char**)malloc(sizeof(char*) * size);
simbols = (char**)malloc(sizeof(char*) * (size * 2 + 1));
unsigned int row = size * 2 + 1;
unsigned int col = size * 4 + 1;

for (unsigned int i = 0; i < size; i++) {
board[i] = (char*)malloc(sizeof(char*) * size);
}

for (unsigned int i = 0; i < col; i++) {
simbols[i] = (char*)malloc(sizeof(char*) * col);
}

// ... 省略棋盘初始化
}

这里我们采用的是通过指针的指针方式来分配, (char**) 首先声明 malloc 分配内存的最终类型,

sizeof(char*) * size 计算具体需要分配多少内存:

board = (char**)malloc(sizeof(char*) * size);

经过这一步后,也是我们的第一步,只是完成了存放指针的内存空间,而这些指针最终指向的是一个数组,即通过下面

1
2
3
for (unsigned int i = 0; i < size; i++) {
board[i] = (char*)malloc(sizeof(char*) * size);
}

的代码,我们计算并分配出每个指针的指针下面需要多少空间。

simbols 的内存分配类似。

内存释放

不要忘记了,拿了多少拿了什么,在不用的时候一定要记得还回去(to_free) :

1
2
3
4
5
6
7
8
9
10
11
12
void to_free() {
for (int i = 0; i < size; i++) {
free(board[i]);
}
free(board);

int col = size * 4 + 1;
for (int i = 0; i < col; i++) {
free(simbols[i]);
}
free(simbols);
}

内存的释放遵循,后分配的先释放(指针的指针),即先释放第二位数组占用的内存,然后释放指向二维数组的指针占用的内存。

修改棋盘输出函数(print_table)

经过内存分配和释放之后,事情就变的很简单了(或者说之前的工作是在考虑到了扩展到 n*n 基础上进行的),这里

只需要将遍历的行和列修改成通过 size 计算而来的就 OK:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void print_table(int loc, char flag) {

located = false;

// 将 7 和 13 修改成 row 和 col 即可
// row 根据两倍加一的规律得来(有效行和分割行)
// col 为四倍加一(竖线-空格-棋子-空格)
unsigned int row= size * 2 + 1;
unsigned int col = size * 4 + 1;
for (unsigned int i = 0; i < row; i++) {
for (unsigned int j = 0; j < col; j++) {
// ... 省略
}
}

// ... 省略
}

到此, n*n 棋盘的修改基本结束。

自定义棋盘大小

通过获取玩家输入来决定玩家想要的棋盘大小和判赢连子数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 获取棋盘大小和赢的连子数
*/
void get_size_and_limit() {
printf("请输入棋盘大小(默认4x4):");
scanf("%d", &size);
printf("请输入判赢连子数(默认3个连子):");
scanf("%d", &win_limit);
if (win_limit > size) {
win_limit = 3;
printf("您输入的连子数超出棋盘大小,将使用默认连子数(3),祝您游戏愉快!\n");
}
printf("您选择的棋盘大小:%d x %d, 连子数:%d\n", size, size, win_limit);
}

问题

一条线上中间隔子依然获胜问题

img

修复代码如下(很简单):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_Bool check_if_win(enum Directions direction, int row, int col, char sign) {
// ... 省略

// 左上角棋子
next_char = board[row][col];
if (next_char == sign) {
total++;
} else {
// 只要有不相同的便退出,因为必须是三个连在一起的才行
break;
}

// ... 省略
}

只需要加个 else { break; } 即可,因为只有三个相连的棋子相同才会获胜,只要有一个不相连则并未获胜。

修复结果如下:

img

该问题解决,本文也完美收官。

段错误(改造成更大棋盘之后)

段错误出现在 check_if_win 检测输赢里面。

出现段错误并非 check_if_win 中,而是在 print_table 的时候

1
2
3
4
5
6
7
8
9
void print_table(int loc, char flag) {

located = false;

// 这里应该是 size * 2 而不是 3,这里4*4的成了12行了,不出错才怪
unsigned int row= size * 3 + 1;

// ... 省略
}

改成 2 就解决了。

gdb 调试

暂未用到 gbd 调试,本是想准备用的,最后通过两个 printf 即找到诊点。

install gdb

mac 上可以通过 brew install gdb 来安装,安装好之后会有证书的问题, brew 安装完成之后有提示网站,按照提示

的网站步骤就可以解决。

gdb a.out

gdb: break a.c:8 设置断点

gbd: next 单步执行(step next)

gdb: run 运行程序

gdb: backtrace 堆栈信息

gdb: frame n 选择哪个堆栈帧

gdb: print varname 打印变量值

gdb: kill 退出

总结

2019 年的计划在于双向并行

  1. 计算机基础,数据结构和算法只类的基础功
  2. 前端框架和 JavaScript 基础

因此该文是基于在学习 C 语言背景之下完成的(应该说回顾吧,毕业第一年干的是嵌入式,对 C 并非完全零基础)。

学习 C 语言目的也是为了能更好的去学习计算机基础以及算法等知识。

如今,总感觉时间不够用,公司现在又完全是一个人在顶着前端的重任,几乎都是满负荷在工作,但是越是这样越感觉自己的能力

不足,基础太薄弱。

因此今年的计划便是重基础,顺带框架地继续往前走,希望未来越来越好,相信自己!!!

本文标题:五子棋游戏-C语言实现

文章作者:ZhiCheng Lee

发布时间:2019年04月22日 - 16:01:45

最后更新:2019年06月18日 - 00:11:24

原始链接:http://blog.gcl666.com/2019/04/22/five_in_a_row_chess/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

0%