/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
游戏项目第二篇|用 C 语言实现三子棋与五子棋,练透棋盘程序设计
上一篇扫雷项目里,我们练习了二维数组、随机数和多文件函数设计。这一篇继续用棋盘做游戏,但把重点放在另一个问题上。
玩家每走一步,程序应当怎样检查操作、更新状态并判断胜负?
三子棋和五子棋很适合回答这个问题。它们共用同一套程序结构,主要差别只有棋盘大小和获胜所需的连续棋子数。我们甚至可以只写一份代码,通过修改三个常量切换游戏。
一、先把两个游戏的共同规则找出来
两个游戏都需要这些功能。
- 初始化一张空棋盘
- 打印棋盘和坐标
- 两名玩家交替落子
- 拒绝越界坐标和重复落子
- 每次有效落子后检查胜负
- 棋盘已满且无人获胜时判为平局
可以用下面的数据表示游戏状态。
| board[ROW][COL] | 保存每个位置的棋子 |
| player | 保存当前玩家,取值为 X 或 O |
| moves | 保存有效落子次数 |
| ROW、COL | 决定棋盘大小 |
| WIN_LEN | 决定获胜所需的连续棋子数 |
空格用字符 ' ' 表示。玩家输入从 1 开始的坐标,数组下标从 0 开始,因此写入数组前必须减一。
三子棋使用 3 × 3 棋盘,WIN_LEN 为 3。五子棋使用 15 × 15 棋盘,WIN_LEN 为 5。
当两个项目拥有相同的数据和动作时,应当优先寻找可复用的实现,避免复制两份代码后分别修补。
二、函数按照游戏动作拆分
| InitBoard | 把棋盘全部置为空格 |
| DisplayBoard | 打印当前棋盘 |
| PlacePiece | 检查坐标并完成一次落子 |
| CountDirection | 统计某个方向上的连续棋子数 |
| IsWin | 检查四个方向是否达到获胜长度 |
| PlayGame | 组织落子、胜负判断和玩家轮换 |
这里有一个重要的设计决定。
PlacePiece 只负责检查并落子,不打印提示,也不切换玩家。IsWin 只读取棋盘并返回结果,不修改游戏状态。PlayGame 负责把这些函数按规则串起来。
函数职责越清楚,主流程就越接近自然语言描述的游戏规则。
三、胜负判断只围绕最后一步展开
新手常写的方案是每走一步就扫描整个棋盘。三子棋只有 9 个格子,这样写没有问题。换成五子棋后,仍可以利用一个更直接的规律。
新形成的连线一定经过刚刚落下的棋子。
因此,只需以最后落子位置为中心,检查四个方向。
每个方向都要向两端统计。例如水平方向需要同时检查左边和右边,再加上当前棋子本身。
连续数量 = 1 + 正方向数量 + 反方向数量;
当某个方向的连续数量大于或等于 WIN_LEN,当前玩家获胜。这个函数既能处理三子棋,也能处理五子棋,无需为两个游戏分别写判断逻辑。
四、完整代码
下面的程序默认运行三子棋。两名玩家共用键盘,依次输入行号和列号。
#include <stdio.h>
#include <stdbool.h>
#define ROW 3
#define COL 3
#define WIN_LEN 3
// 初始化棋盘
static void InitBoard(char board[ROW][COL])
{
for (int i = 0; i < ROW; i++)
for (int j = 0; j < COL; j++)
board[i][j] = ' ';
}
// 打印棋盘,坐标从 1 开始显示
static void DisplayBoard(const char board[ROW][COL])
{
printf("\\n ");
for (int j = 1; j <= COL; j++)
printf("%2d ", j);
printf("\\n");
for (int i = 0; i < ROW; i++)
{
printf("%2d ", i + 1);
for (int j = 0; j < COL; j++)
printf(" %c ", board[i][j]);
printf("\\n");
}
printf("\\n");
}
// 合法落子返回 true,失败时不修改棋盘
static bool PlacePiece(char board[ROW][COL], int row, int col, char player)
{
if (row < 1 || row > ROW || col < 1 || col > COL)
return false;
int x = row – 1; // 玩家坐标转换为数组下标
int y = col – 1;
if (board[x][y] != ' ')
return false; // 已经有棋子的位置不能重复落子
board[x][y] = player;
return true;
}
// 从当前棋子的相邻位置开始,统计单一方向上的连续棋子
static int CountDirection(const char board[ROW][COL], int x, int y,
int dx, int dy, char player)
{
int count = 0;
x += dx;
y += dy;
// 同时检查数组边界和棋子是否属于当前玩家
while (x >= 0 && x < ROW && y >= 0 && y < COL &&
board[x][y] == player)
{
count++;
x += dx;
y += dy;
}
return count;
}
// x、y 是最后落子的数组下标
static bool IsWin(const char board[ROW][COL], int x, int y, char player)
{
const int directions[4][2] = {
{0, 1}, // 水平
{1, 0}, // 垂直
{1, 1}, // 主对角线
{1, –1} // 副对角线
};
for (int i = 0; i < 4; i++)
{
int dx = directions[i][0];
int dy = directions[i][1];
int count = 1; // 当前棋子先算一个
count += CountDirection(board, x, y, dx, dy, player);
count += CountDirection(board, x, y, –dx, –dy, player);
if (count >= WIN_LEN)
return true;
}
return false;
}
// 组织一局游戏
static void PlayGame(void)
{
char board[ROW][COL];
char player = 'X';
int moves = 0;
InitBoard(board);
while (true)
{
DisplayBoard(board);
printf("玩家 %c,请输入行和列,例如 1 2\\n", player);
int row = 0;
int col = 0;
if (scanf("%d %d", &row, &col) != 2)
{
printf("输入无效,请输入两个整数\\n");
// 清理本行残留输入,避免错误内容被反复读取
while (getchar() != '\\n')
;
continue;
}
if (!PlacePiece(board, row, col, player))
{
printf("坐标越界或位置已有棋子,请重新输入\\n");
continue;
}
moves++; // 只有有效落子才增加次数
if (IsWin(board, row – 1, col – 1, player))
{
DisplayBoard(board);
printf("玩家 %c 获胜\\n", player);
return;
}
if (moves == ROW * COL)
{
DisplayBoard(board);
printf("棋盘已满,双方平局\\n");
return;
}
// 当前玩家没有获胜,也没有平局,交换执子方
player = (player == 'X') ? 'O' : 'X';
}
}
int main(void)
{
int choice = 0;
do
{
printf("1. 开始游戏\\n0. 退出游戏\\n请选择\\n");
if (scanf("%d", &choice) != 1)
{
while (getchar() != '\\n')
;
choice = –1;
}
if (choice == 1)
PlayGame();
else if (choice != 0)
printf("选择错误,请重新输入\\n");
} while (choice != 0);
return 0;
}
使用 GCC 编译和运行。
gcc board_game.c -std=c11 -Wall -o board_game
./board_game
使用 Visual Studio 时,将代码加入一个空项目即可。MSVC 可能对 scanf 给出安全提醒,学习阶段可按课程环境选择 GCC、Clang 或相应的安全输入函数。
五、改成五子棋只需三行
找到文件开头的常量定义,改成下面的内容。
#define ROW 15
#define COL 15
#define WIN_LEN 5
其余代码保持不变。棋盘打印、坐标检查、落子、轮换玩家、四方向统计和平局判断都能继续工作。
这就是参数化设计带来的收益。把会变化的规则提取成常量,把不变的处理过程留在函数里。 如果以后想做一个 10×10 的四连棋,也只需重新调整配置。
需要说明的是,这里的五子棋采用基础规则,连续五颗或更多棋子都算获胜,暂未加入禁手、交换规则等正式竞赛内容。学习项目可以逐步增加规则,没必要第一版就把整本规则书塞进 main。
六、这组测试比正常玩一局更重要
代码写完以后,建议主动构造下面的测试。
| 输入 0 1 或超出棋盘的坐标 | 拒绝落子,玩家不切换 |
| 两次输入同一坐标 | 第二次被拒绝,moves 不增加 |
| 输入字母 | 提示输入错误,程序不会反复刷屏 |
| 横向、纵向连成目标长度 | 正确获胜 |
| 两条对角线连成目标长度 | 正确获胜 |
| 连线位于棋盘边缘 | 正确判断且没有数组越界 |
| 棋盘填满且无人获胜 | 正确判为平局 |
| 五子棋出现六连 | 按本文基础规则判为获胜 |
还可以故意把数组下标的减一删掉,观察程序发生什么,再恢复代码。这样比单纯记住结论更容易理解坐标转换和边界检查。
测试的目标是让规则在各种输入下都成立,而不只是证明自己精心挑选的一步棋能运行。
七、下一步可以怎样扩展
完成基础版本后,可以选择一个方向继续练习。
其中最值得先做的是拆分文件和加入简单电脑玩家。前者训练工程组织,后者训练把策略拆成明确步骤。
写在最后
三子棋和五子棋的代码不算长,却包含了游戏程序反复出现的几个动作。读取输入、验证操作、修改状态、检查结果、切换回合,再继续下一轮。
如果你能脱离这份代码,独立画出程序流程并重新实现一次,就已经在完成一次真正的程序设计训练。遇到问题时,先把故障缩小到一次落子或一个方向,逐步打印坐标和计数。编译器可能偶尔让人头疼,但它至少不会偷偷挪动你的棋子,耐心查下去,问题通常比想象中具体。


