想不想了解一个Electron版本的五子棋人机对战游戏如何开发?

288 阅读11分钟

前言

之前写了一个网页版的五子棋游戏, 参见自娱自乐--写个简易的五子棋游戏玩一玩, 这个网页版的五子棋程序,只能真人对决,而人机对决的场景可能更高频,因为你不能随时随地匹配到一个玩家,为此想把这个功能补上。另外,单机游戏的话,无需联网,用桌面应用比网页作为载体更好,故而这一次打算用Electron实现一下这个游戏。

效果演示

玩法是可以选择人先落子,也可以选择电脑先落子。可以看到,电脑玩家的响应速度很快,人类玩家刚落子,电脑玩家根据当前棋盘的落子情况,立刻就计算好了该把棋子放置在哪里,而且表现了出一定的水平,段位不太高的新手,想赢电脑还是有些困难的。

电脑算法实现步骤

有两点先阐述一下:

  1. 如何绘制棋盘,判断输赢,以及该谁落子的状态提示逻辑,在这篇文章中自娱自乐--写个简易的五子棋游戏玩一玩有说明。
  2. 如何创建一个Electron桌面应用,请参考Electron桌面应用开发实践

本文重点说明一下,此次添加的增量内容,电脑五子棋算法的实现原理。五子棋中的五元组+计分表算法是一种经典的棋局评估方法,用于帮助电脑判断当前棋局的形势,评估棋盘上各个位置的得分,以便选择最优的下子位置。

五元组分值计算

1. 五元组的概念

是指在五子棋棋盘上任意连续的五个格子。五元组可以是水平、垂直、或斜向(正斜线和反斜线)的连续五个格子。每个五元组在评估时都会被赋予一个分数,代表该五元组对当前玩家的有利程度。棋盘上的每个格子可能是多个五元组的一部分。例如,一个格子可能同时在多个水平、垂直、斜向的五元组中(如下图的十字交叉中心红点)。因此,累加这些五元组的分数,可以反映出该格子在整个棋局中的战略价值。

image.png

2. 算法的具体实现

2.1 初始化计分表

首先,初始化一个二维数组 scoreBoard,大小与棋盘相同,用来记录每个位置的得分。

2.2 扫描棋盘并统计五元组

对棋盘上的每一个格子,沿着四个方向(水平、垂直、正斜线、反斜线)扫描,检查以该格子为起点的五元组中先手和后手的落子数量。

2.3 五元组得分计算

对于每一个五元组,根据其中黑白棋子的数量分布,从计分表中查找对应的分数。将这个分数加到五元组中每个空位的 scoreBoard 上。具体步骤如下:

  1. 己方棋子计数:统计五元组中己方棋子的数量。
  2. 对方棋子计数:统计五元组中对方棋子的数量。
  3. 计分:根据己方和对方棋子的数量的分布,从计分表中找到对应的分数,并将这个分数累加到五元组中每个 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 函数)中,playerNumcomputerNum 的分值不同以及先判断 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分钟能坚持下来,那就能克服拖延症。 本文的代码已经上传到码云,如果你想了解完整的功能实现,可以点击这里下载,有问题欢迎指正。