快速阅读

记录上学时做过的一个国际象棋软件。

功能概述

  1. 玩家对战:玩家之间轮流控制各自的棋子,吃掉对方的王获得胜利。合法的落点高亮,不合法的落点不对鼠标做出响应。
  2. 人机对战:玩家通过鼠标控制自己的棋子,AI通过搜索算法获得将要移动的棋子及移动的目标位置。AI走棋没有控制阶段但设置了完整的过渡动画。
  3. 控制面板:控制面板能够修改游戏模式、重置游戏进度、调整AI难度。
  4. 特殊规则:
    • 兵的升变:本方任何一个兵直进达到对方底线时,即可升变为除 “王” 和 “兵” 以外的任何一种棋子。
    • 王车易位:这是国际象棋中比较特殊的行棋规则,车和王在一些情况下可以互换位置。
    • 吃过路兵:如果对方的兵第一次行棋且直进两格,刚好形成本方有兵与其横向紧贴并列,则本方的兵可以立即斜进,把对方的兵吃掉,并视为一步棋。

核心节点

搜索算法

deepSearch(depth, chessBoard, AIRound, parentIndex)

  • depth 代表搜索的深度,每次递归 -1
  • chessBoard 是搜索算法需要用到的棋盘数组。
  • AIRound 用来判断此轮搜索是否为 AI 走棋,若为AI走棋则遍历 AI 方的所有棋子的所有可行走法,并为生成的每个 chessBoard 创建一个树的结点,根节点即最初搜索时的 chessBoard 参数;若为玩家走棋同样遍历玩家方的所有棋子的所有可行的走法,但只创建一个结点,这个节点是所有生成的 chessBoard 中价值最小的,因为玩家走棋时理应选择对 AI 最不利的走法,即评价函数返回值最小的。

最终生成树是这样的:

deepSearch 函数生成树

不管 depth 值为多少,最终 AI 要选择的总是第一步的走法。无论 depth 是奇数还是偶数,只需要选择所有的叶子结点中价值最大的结点,并回溯到第一步的祖先节点,即为 AI 的最佳走法。

评价函数

AI 需要在所有可行的走法中选择一个最合适的,是否是最合适需要利用评价函数对棋盘的状态进行量化,选择值最大的作为最终决策。评价函数接收一个棋盘数组(记录了棋盘上的棋子分布)作为参数,累加棋盘上所有存在的棋子的价值(包括棋子的基础价值和基于棋子位置的偏移价值),将它们的和作为函数返回值。

线程阻塞

deepSearch 函数在 JS 主线程内执行时,若 depth 较高(>=6 时),耗费的时间会比较长,在这段时间内页面无法渲染,动画无法执行,会造成明显的视觉卡顿。为了解决这个问题,可单独创建 worker 线程执行 deepSearch 函数,此时主线程不会被阻塞,页面会及时刷新,不会出现卡顿。Worker 线程的 deepSearch 函数执行完毕时通过 postMessage 方法向主线程发送数据(包括将要移动的棋子坐标和要移动的目标位置),主线程通过 onmessage 方法接收,然后执行 AIset 函数,移动目标棋子并更新 chessBoard 棋盘数组。

运行效果

玩家对战
棋子落点
人机对战
游戏结束
控制面板
难度调整
升变选择

AI 算法

这里贴上以前花了几天时间摸索并总结的国际象棋 AI 算法。

核心功能

告诉 AI 要移动哪个棋子,以及把这个棋子移动到哪个位置。

从函数上看,AI 算法需要返回两个坐标,即 要移动的棋子坐标目标位置的坐标

遍历棋盘

以初始状态的棋盘作为参数(棋盘通常是一个 8*8 的二维数组,用于记录棋盘上的棋子分布),遍历棋盘上的所有己方棋子,对每个己方棋子,遍历它所有可行的走法,每种走法都生成一个 结点

结点的数据结构由两部分组成:

  1. 本次移动的棋子坐标以及移动的目标位置坐标,它将作为函数的返回值。
  2. AI 算法执行时需要用到的一些临时变量。

对于生成的每个结点(每一个结点代表着一次 行棋),将行棋后的棋盘作为参数,并交换行棋回合。例如:

第一次遍历是 AI 行棋,遍历的是 AI 方的棋子;第二次遍历则交换为玩家行棋,遍历玩家方的棋子,每次交换回合视为搜索深度 +1

递归遍历 所有己方棋子 及其 所有可行的走法,一直到设定的深度时结束。这样就得到了在 depth 个回合内(depth 即设定的深度)所有可能产生的结果。

评价策略

评价函数 接收一个棋盘数组(记录了棋盘上的棋子分布)作为参数,根据当前棋盘的局势,返回一个数值作为当前棋盘的 局面评价

评价策略一般是累加棋盘上所有存在的棋子的价值,将它们的和作为函数返回值。

棋子的价值由基础价值和偏移价值组成。

基础价值

  • ♟ 兵:10.0
  • ♞ 马:32.5
  • ♝ 象:35.0
  • ♜ 车:50.0
  • ♛ 后:97.5
  • ♚ 王:900.0

对方棋子的基础价值为己方同类棋子基础价值的 相反数(即取负值)。

偏移价值

偏移价值是一个 8*8 的二维数组,代表此类棋子在棋盘上不同位置时对应的价值偏移量,不同类型棋子的偏移数组是不同的。

基础价值和偏移数组对应坐标的值之和即为棋子的价值。

评价函数的返回值是 决策 的依据,因此 评价策略 是体现 AI 算法差异及 AI 强度的核心因素。

决策策略

在所有得到的结果中选择一个,并回溯到它 第一层的祖先结点(不管遍历深度为多少,最终要选择的总是第一层的某一种走法,因为只有第一层的遍历是基于初始状态的棋盘),将此结点保存的两个坐标作为函数返回值。

如何选择取决于算法的策略,这是体现 AI 算法差异及 AI 强度的重要因素。

剪枝策略

遍历到某一个结点时,根据某种策略进行判断:此结点是否可能作为最终决策?若可能则继续递归遍历其子结点;若不可能则停止递归。

剪枝 不会影响最终决策,因为被剪枝的是不可能作为最终决策的结点,但剪枝可以降低算法的时间复杂度,减少执行时间

剪枝的策略是体现 AI 算法差异的重要因素,它 可能会影响到 AI 的强度

剪枝不会影响最终决策 是建立在剪枝正确的前提下,即被剪枝的一定是不可能作为最终决策的结点,若剪枝错误则会影响到最终决策。

搜索方式

搜索可以采用:

  • 深度优先搜索(优先递归 某结点的所有子结点)
  • 广度优先搜索(优先遍历 同一深度的其他结点)

广度优先搜索相对于深度优先搜索会更适合于剪枝策略,因为深度优先搜索会优先递归,递归之前会进行剪枝判断,此时并未生成同一深度的其他结点,剪枝策略在判断是否应该递归时的依据会非常有限(剪枝策略的依据是已经生成的所有结点)。

深度搜索的方式会影响到剪枝的策略,可能会间接影响到 AI 的强度。

实现过程

数据结构

非叶子结点

  • 本次行棋控制的棋子的坐标(用于返回值)
  • 本次行棋移动的目标位置(用于返回值)
  • 此结点的父节点序号(用于回溯到第一层的祖先结点)

叶子结点

  • 本次行棋控制的棋子的坐标(用于返回值)
  • 本次行棋移动的目标位置(用于返回值)
  • 此结点的父节点序号(用于回溯到第一层的祖先结点)
  • 当前棋盘的价值(即评价函数返回值,用于决策)

搜索方式

深度优先搜索,即优先递归。

评价策略

基础价值

棋子 基础价值
♟ 兵 ± 10
♞ 马 ± 32.5
♝ 象 ± 35
♜ 车 ± 50
♛ 后 ± 97.5
♚ 王 ± 900

偏移价值

共 12 个 8*8 的二维数组,例如 ♞ 马 的偏移数组:

1
2
3
4
5
6
7
8
9
10
[
[-5, -4, -3, -3, -3, -3, -4, -5],
[-4, -2, 0, 0, 0, 0, -2, -4],
[-3, 0, 1, 1.5, 1.5, 1, 0, -3],
[-3, 0.5, 1.5, 2, 2, 1.5, 0.5, -3],
[-3, 0, 1.5, 2, 2, 1.5, 0, -3],
[-3, 0.5, 1, 1.5, 1.5, 1, 0.5, -3],
[-4, -2, 0, 0.5, 0.5, 0, -2, -4],
[-5, -4, -3, -3, -3, -3, -4, -5],
];

决策策略

在所有叶子结点中选择评价函数返回值最大的结点,回溯到它第一层的祖先结点,将此祖先结点作为最终决策。

剪枝策略

  1. 对于 AI 走棋的深度,不做剪枝,遍历 AI 方的所有棋子及其所有走法,并递归所有子结点。
  2. 对于玩家走棋的深度,遍历玩家方的所有棋子及其所有走法,保留评价函数返回值最小的结点并递归此结点,剪掉其他的结点。这样做的理由是默认玩家方会选择最不利于 AI 方的走法,因此只保留和递归评价函数返回值最小的结点,由此生成的决策树所有的叶子结点一定在最大的深度。

循环修正

有时 AI 会在同一个位置来回走子,原因是它的决策树中出现了多个评价值相同且最高叶子节点,由于程序顺序执行的原因导致 AI 总是选择这些叶子结点中的第一个,可以修改决策策略,使其在所有评价值最高的叶子结点中随机选择一个,就能避免此类问题。


  1. 五子棋
  2. 国际象棋
  3. 媒体播放器
  4. 音频播放器