前言
之前写了一个网页版的五子棋游戏, 参见自娱自乐--写个简易的五子棋游戏玩一玩, 这个网页版的五子棋程序,只能真人对决,而人机对决的场景可能更高频,因为你不能随时随地匹配到一个玩家,为此想把这个功能补上。另外,单机游戏的话,无需联网,用桌面应用比网页作为载体更好,故而这一次打算用Electron实现一下这个游戏。
效果演示
玩法是可以选择人先落子,也可以选择电脑先落子。可以看到,电脑玩家的响应速度很快,人类玩家刚落子,电脑玩家根据当前棋盘的落子情况,立刻就计算好了该把棋子放置在哪里,而且表现了出一定的水平,段位不太高的新手,想赢电脑还是有些困难的。

电脑算法实现步骤
有两点先阐述一下:
- 如何绘制棋盘,判断输赢,以及该谁落子的状态提示逻辑,在这篇文章中自娱自乐--写个简易的五子棋游戏玩一玩有说明。
- 如何创建一个Electron桌面应用,请参考Electron桌面应用开发实践
本文重点说明一下,此次添加的增量内容,电脑五子棋算法的实现原理。五子棋中的五元组+计分表算法是一种经典的棋局评估方法,用于帮助电脑判断当前棋局的形势,评估棋盘上各个位置的得分,以便选择最优的下子位置。
五元组分值计算
1. 五元组的概念
是指在五子棋棋盘上任意连续的五个格子。五元组可以是水平、垂直、或斜向(正斜线和反斜线)的连续五个格子。每个五元组在评估时都会被赋予一个分数,代表该五元组对当前玩家的有利程度。棋盘上的每个格子可能是多个五元组的一部分。例如,一个格子可能同时在多个水平、垂直、斜向的五元组中(如下图的十字交叉中心红点)。因此,累加这些五元组的分数,可以反映出该格子在整个棋局中的战略价值。
2. 算法的具体实现
2.1 初始化计分表
首先,初始化一个二维数组 scoreBoard,大小与棋盘相同,用来记录每个位置的得分。
2.2 扫描棋盘并统计五元组
对棋盘上的每一个格子,沿着四个方向(水平、垂直、正斜线、反斜线)扫描,检查以该格子为起点的五元组中先手和后手的落子数量。
2.3 五元组得分计算
对于每一个五元组,根据其中黑白棋子的数量分布,从计分表中查找对应的分数。将这个分数加到五元组中每个空位的 scoreBoard 上。具体步骤如下:
- 己方棋子计数:统计五元组中己方棋子的数量。
- 对方棋子计数:统计五元组中对方棋子的数量。
- 计分:根据己方和对方棋子的数量的分布,从计分表中找到对应的分数,并将这个分数累加到五元组中每个
scoreBoard上。
2.4 选择最优位置
在遍历完棋盘后,每个位置在 scoreBoard 上都有一个对应的分数。AI可以根据 scoreBoard 中的得分,选择得分最高的位置进行落子。
下面是具体的代码实现:
// 电脑下棋算法
export function findBestMove(board, computerIsFirst) {
let goalX = -1,
goalY = -1;
const chessPlace = convertArray(board, ChessSize + 1);
// 初始化score评分组
for (let i = 0; i < ChessSize; i++) {
for (let j = 0; j < ChessSize; j++) {
score[i][j] = 0;
}
}
// 五元组中黑棋(先手)落子数量
let frontNum = 0;
// 五元组中白棋(后手)落子数量
let backNum = 0;
// 五元组临时得分
let tempScore = 0;
// 最大得分
let maxScore = -1;
// 横向寻找
for (let i = 0; i < ChessSize; i++) {
for (let j = 0; j < ChessSize - 4; j++) {
for (let k = 0; k < j + 5; k++) {
// 如果是玩家落得子
if (chessPlace[i][k] == 1) {
frontNum++;
} else if (chessPlace[i][k] == 2) {
//如果是电脑落子
backNum++;
}
}
// 将每一个五元组中的黑棋和白棋个数传入评分表中
tempScore = chessScore(frontNum, backNum, computerIsFirst);
// 为该五元组的每个位置添加分数
for (let k = j; k < j + 5; k++) {
score[i][k] += tempScore;
}
// 清空五元组中棋子数量和瞬时分数值
frontNum = 0;
backNum = 0;
tempScore = 0;
}
}
// 纵向寻找
for (let i = 0; i < ChessSize; i++) {
for (let j = 0; j < ChessSize - 4; j++) {
for (let k = j; k < j + 5; k++) {
// 统计横向先手的棋子数量
if (chessPlace[k][i] == 1) {
frontNum++;
} else if (chessPlace[k][i] == 2) {
// 统计横向后手的棋子数量
backNum++;
}
}
// 将每一个五元组中的黑棋和白棋个数传入评分表中
tempScore = chessScore(frontNum, backNum, computerIsFirst);
// 为该五元组的每个位置添加分数
for (let k = j; k < j + 5; k++) {
score[k][i] += tempScore;
}
// 清空五元组中棋子数量和五元组临时得分
frontNum = 0;
backNum = 0;
tempScore = 0;
}
}
// 反斜线寻找
// 反斜线上侧部分
for (let i = ChessSize - 1; i >= 4; i--) {
for (let k = i, j = 0; j < ChessSize && k >= 0; j++, k--) {
let m = k; //x 14 13
let n = j; //y 0 1
for (; m > k - 5 && k - 5 >= -1; m--, n++) {
// 如果是玩家落得子
if (chessPlace[m][n] == 1) {
frontNum++;
} else if (chessPlace[m][n] == 2) {
//如果是电脑落子
backNum++;
}
}
// 注意在斜向判断时,可能出现构不成五元组(靠近棋盘的四个顶角)的情况,所以要忽略这种情况
if (m == k - 5) {
// 将每一个五元组中的黑棋和白棋个数传入评分表中
tempScore = chessScore(frontNum, backNum, computerIsFirst);
// 为该五元组的每个位置添加分数
for (m = k, n = j; m > k - 5; m--, n++) {
score[m][n] += tempScore;
}
}
// 清空五元组中棋子数量和五元组临时得分
frontNum = 0;
backNum = 0;
tempScore = 0;
}
}
// 反斜线下侧部分
for (let i = 1; i < 15; i++) {
for (let k = i, j = ChessSize - 1; j >= 0 && k < 15; j--, k++) {
let m = k; //y 1
let n = j; //x 14
for (; m < k + 5 && k + 5 <= 15; m++, n--) {
// 如果是玩家落得子
if (chessPlace[n][m] == 1) {
frontNum++;
} else if (chessPlace[n][m] == 2) {
//如果是电脑落子
backNum++;
}
}
// 注意在斜向判断时,可能出现构不成五元组(靠近棋盘的四个顶角)的情况,所以要忽略这种情况
if (m == k + 5) {
// 将每一个五元组中的黑棋和白棋个数传入评分表 中
tempScore = chessScore(frontNum, backNum, computerIsFirst);
// 为该五元组的每个位置添加分数
for (m = k, n = j; m < k + 5; m++, n--) {
score[n][m] += tempScore;
}
}
// 清空五元组中棋子数量和五元组临时得分
frontNum = 0;
backNum = 0;
tempScore = 0;
}
}
// 正斜线寻找
// 正斜线上侧部分
for (let i = 0; i < ChessSize - 1; i++) {
for (let k = i, j = 0; j < ChessSize && k < ChessSize; j++, k++) {
let m = k;
let n = j;
for (; m < k + 5 && k + 5 <= ChessSize; m++, n++) {
// 如果是玩家落得子
if (chessPlace[m][n] == 1) {
frontNum++;
} else if (chessPlace[m][n] == 2) {
//如果是电脑落子
backNum++;
}
}
// 注意在斜向判断时,可能出现构不成五元组(靠近棋盘的四个顶角)的情况,所以要忽略这种情况
if (m == k + 5) {
// 将每一个五元组中的黑棋和白棋个数传入评分表中
tempScore = chessScore(frontNum, backNum, computerIsFirst);
// 为该五元组的每个位置添加分数
for (m = k, n = j; m < k + 5; m++, n++) {
score[m][n] += tempScore;
}
}
// 清空五元组中棋子数量和五元组临时得分
frontNum = 0;
backNum = 0;
tempScore = 0;
}
}
// 正斜线下侧部分
for (let i = 1; i < ChessSize - 4; i++) {
for (let k = i, j = 0; j < ChessSize && k < ChessSize; j++, k++) {
let m = k;
let n = j;
for (; m < k + 5 && k + 5 <= ChessSize; m++, n++) {
// 如果是玩家落得子
if (chessPlace[n][m] == 1) {
frontNum++;
} else if (chessPlace[n][m] == 2) {
//如果是电脑落子
backNum++;
}
}
// 注意在斜向判断时,可能出现构不成五元组(靠近棋盘的四个顶角)的情况,所以要忽略这种情况
if (m == k + 5) {
// 将每一个五元组中的黑棋和白棋个数传入评分表中
tempScore = chessScore(frontNum, backNum, computerIsFirst);
// 为该五元组的每个位置添加分数
for (m = k, n = j; m < k + 5; m++, n++) {
score[n][m] += tempScore;
}
}
// 清空五元组中棋子数量和五元组临时得分
frontNum = 0;
backNum = 0;
tempScore = 0;
}
}
// 从空位置中找到得分最大的位置
for (let i = 0; i < ChessSize; i++) {
for (let j = 0; j < ChessSize; j++) {
if (chessPlace[i][j] == 0 && score[i][j] > maxScore) {
goalX = i;
goalY = j;
maxScore = score[i][j];
}
}
}
if (goalX != -1 && goalY != -1 && chessPlace[goalX][goalY] == 0) {
// 落子
return goalX * 16 + goalY;
}
}
评分规则
在评分表(chessScore 函数)中,playerNum 和 computerNum 的分值不同以及先判断 computerNum 是基于以下几个关键考虑因素:
1. 攻防策略的差异
- 进攻优先:在许多棋类游戏的AI设计中,进攻通常比防守更重要。因此,电脑在进攻时的分值设定会更高,这意味着电脑在考虑落子时,更倾向于优先选择进攻,而不是防守。
- 防守分数较低:当玩家形成了某种威胁时,电脑需要防守,但防守的分数通常低于进攻分数。这样设计的目的是为了使电脑更加积极地寻找获胜机会,而不是仅仅被动防守。
2. 先判断 computerNum 的原因
- 进攻为主:函数中首先判断
computerNum(表示电脑的棋子数量)来确保进攻策略的优先级。如果电脑在某个五元组中占据了有利位置,它会首先考虑这个五元组的分数,以决定是否继续进攻。 - 交换角色:在判断
computerNum之前,代码中有一个角色交换的逻辑(if (computerIsFirst == true)), 这个评分表预设电脑是后手, 得出的结果才对电脑有利 当电脑先走时,要互换一下角色, 这样电脑仍然优先考虑进攻策略。
3. 分值差异的设计理由
- 进攻分值更高:当
computerNum中有更多的棋子时,分值设定得更高(例如,3个子时分值为15000,4个子时分值为800000),这使得AI更倾向于进攻,尝试形成五连。 - 防守分值较低:当
playerNum中有更多的棋子时,AI的防守分数虽然也高,但相对进攻分数低一些。这使得AI在无法进攻时,会采取防守,但仍然会优先考虑进攻机会。
4. 特定评分的设计意图
- 防止冲突:如果一个五元组中既有人类的子也有电脑的子(
playerNum > 0 && computerNum > 0),那么这个五元组不可能形成五连,因此评分为0。 - 空白五元组:如果五元组完全空白(
playerNum == 0 && computerNum == 0),这个五元组的潜力较小,评分也较低,为7。
代码实现:
// 五元组评分表
function chessScore(playerNum, computerNum, computerIsFirst) {
// 机器进攻
// 1.既有人类落子,又有机器落子,判分为0
if (playerNum > 0 && computerNum > 0) {
return 0;
}
// 2.全部为空没有棋子,判分为7
if (playerNum == 0 && computerNum == 0) {
return 7;
}
// 这个评分表预设电脑是后手, 得出的结果才对电脑有利
// 当机器先走时,要互换一下角色,得出的结果才是对电脑有利
if (computerIsFirst == true) {
let temp = playerNum;
playerNum = computerNum;
computerNum = temp;
}
// 3.机器落一子,判分为35
if (computerNum == 1) {
return 35;
}
// 4.机器落两子,判分为800
if (computerNum == 2) {
return 800;
}
// 5.机器落三子,判分为15000
if (computerNum == 3) {
return 15000;
}
// 6.机器落四子,判分为800000
if (computerNum == 4) {
return 800000;
}
// 机器防守
// 7.玩家落一子,判分为15
if (playerNum == 1) {
return 15;
}
// 8.玩家落两子,判分为400
if (playerNum == 2) {
return 400;
}
// 9.玩家落三子,判分为1800
if (playerNum == 3) {
return 1800;
}
// 10.玩家落四子,判分为100000
if (playerNum == 4) {
return 100000;
}
return -1; //如果是其他情况,则出现错误,不会执行该段代码
}
最后
至此,电脑五子棋算法就实现了,综合反应速度和落子质量,这个算法还是不错的,一般人未必能下赢它。顺便说一句,本来计划上一个月前就该写完的,因为自己的懒散一直拖延到现在,今天终于完成了,感到内心非常充实。感觉克服拖延症的法宝就是对于自己不想干的事情,先坚持10分钟,从简单的地方着手,如果第一个10分钟能坚持下来,那就能克服拖延症。 本文的代码已经上传到码云,如果你想了解完整的功能实现,可以点击这里下载,有问题欢迎指正。