一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
代码仓库,走过路过点一个 Star ✨
做这个的动机
其实一开始我并不打算做一个 AI 出来,因为我觉得挺难写的,实际上真的挺难写的,我的小伙伴在看到我的国际象棋之后,给仓库提了一个 issue 让我 add AI。也提了挺久了,我寻思着清明节在家可以写上一写。实际上我昨天写了一整天。
项目的改造
我们在增加 AI 之前,需要考虑到一写功能,比如 AI 玩黑棋还是白棋,关闭 AI,选择 Ai 等级这些功能。
对于 Context 部分的改造
export interface IAIProps {
value: boolean;
level: number;
turn: string;
}
export interface HAIProps {
playWhite: () => void;
playBlack: () => void;
human: () => void;
setLevel: (level: number) => void;
}
// ...
interface BoardContextProps {
AI: IAIProps;
aiFns: HAIProps;
...
}
const BoardContenxtProvider: FC = ({ children }) => {
const [isAI, setToAI] = useState({
value: false,
level: 0,
});
return <BoardContext.Provider
value={{
...,
AI,
aiFns,
}}>
{children}
</BoardContext.Provider>
}
其实在 Context 中大篇幅的去写这个功能是会让以后代码产生心智负担的,所以我把这个封装成了一个 Custom Hooks,这样上述代码就会变得非常干净,就是一个 Context。
import { useState, useMemo } from "react";
export interface IAIProps {
value: boolean;
level: number;
turn: string;
}
export interface HAIProps {
playWhite: () => void;
playBlack: () => void;
human: () => void;
setLevel: (level: number) => void;
}
const useAI = (init: IAIProps): [ai: IAIProps, fns: HAIProps] => {
const [ai, setAI] = useState(init);
const fns = useMemo(() => ({
playWhite: () => {
setAI({
...ai,
value: true,
turn: "w"
});
},
playBlack: () => {
setAI({
...ai,
value: true,
turn: "b"
});
},
human: () => {
setAI({
...ai,
value: false,
turn: "w"
});
},
setLevel: (level: number) => {
setAI({
...ai,
level
});
}
}), [ai])
return [ai, fns]
}
export default useAI;
UI 改造
所以在这个 UI 里面调用这些方法就非常简单,我并不需要在 tsx 中大篇幅的去操作 Context 的数据,这样代码的可读性也会大幅的提上,因为它看起来就和 html 没有什么区别。
在调用的地方对于 Context 中的值来操作会导致你对于这个 Context 值修改的代码散落在各个文件中。因为 useEffect 的存在,可能你会遇到一个问题,那就是 useEffect 调用的顺序,如果在大型工程中,你可能会觉得我明明没有写这一段逻辑,为什么我的代码里面值变化了?为什么就执行了?这时候一个全新的 Bug 就产生了,有时候修改了起来可能需要花上一天时间。
所以我们把操作数据的方法都放到一个地方去统一的执行,这样前人栽树后人乘凉。
<h4>AI? {ai.value ? 'ON' : 'OFF'}</h4>
<h5>AI Play {ai.turn === 'w' ? 'White' : 'Black'}</h5>
<div className="row">
<button className='btn-small ' onClick={aiFns.playWhite}>AI Play White</button>
<button className='btn-small ' onClick={aiFns.playBlack}>AI Play Black</button>
<button className='btn-small ' onClick={aiFns.human}>Human</button>
</div>
STUPID VERSION
设计原理
一个愚蠢的AI需要怎么设计呢,当然是随机啦,从当前可以移动的 moves 中随机的 pick 一个出来就行了。
代码实现
chess.js 实际上提供了除了 AI 之外所有可以使用的方法,而在我之前几篇文章中就写到了 FEN 这个记法,这是一个用于描述当前棋局的格式。我们这里主要用到 chess.js 提供的两个方法 moves() 和 fen()
import Chess from 'chess.js';
import shuffle from 'lodash/shuffle';
export const stupidVersion = (fen: string) => {
const chessboard = new Chess(fen);
const moves = chessboard.moves();
return shuffle(moves)[0];
}
useEffect(() => {
// play black
if(isAI.value && turn === 'b') {
// use AI
const fen = chessboard.fen();
const stupidMove = stupidVersion(fen);
console.log(fen);
console.log(stupidMove);
setTimeout(() => {
chessboard.move(stupidMove);
setTurn(chessboard.turn());
}, 1000);
}
}, [turn, isAI, chessboard, setTurn]);
关于shuffle这个方法,实际上我更喜欢这种能直观表达的函数名,而不愿意去写 Math.random()这个方法的,虽然达到的效果可能是一样的。
一种头铁的优化
在熟悉了规则之后,我们其实知道各个棋子的战术价值,比如后最🐂🍺,那么我们是否可以优先吃掉这个后,Checkmate 出现在可能的 moves 中,我们会优先选择 Checkmate,那么我们在判断这些字符串的时候,就应该是 # > + > x 这样的一种规则,那么我愚蠢的 AI 会提高那么一丢丢的胜率。
高级的版本
简介
对于高级版本来说,其实就是对于状态图搜索如何更快更准确,这个地方其实需要两个东西,一是估值函数,而是搜索算法。当然之前头铁的优化其实也是估值函数,只不顾答案可能不那么尽如人意。
一个愚蠢的 AI 需要具备什么❓
- 一个愚蠢的估值函数
- 一个慢的和🐢一样的算法
在这里,对于棋类游戏双方对弈的零和游戏,其实我们都是用的差不多的搜索算法,深度优先搜索,而一个国际象棋 AI 可能有的算法大概有三种
对于棋盘价值的 Evaluation 函数
在下面代码中,其实也只是简单的计算了每个棋子的战术价值,然后得出了一个棋盘总体的评价,实际上一个聪明的 AI 中这个函数会非常复杂,我昨天边写边看也看了很久,可以参考 Chess Programming Wiki 这个对于一个写国际象棋 AI 是有很大帮助的。
evaluate(fen: string) {
const chessboard = new Chess(fen);
const scoresArray = chessboard.board().map((row: any[]) => {
return row.map((piece) => {
if (piece && piece.color === 'w') {
return PLAY_WHITE_SCORE[piece.type.toUpperCase()];
}
if (piece && piece.color === 'b') {
return PLAY_BLACK_SCORE[piece.type];
}
return 0;
})
})
const score = sum(flatten(scoresArray));
console.log('score: ', score);
return score;
}
Minmax
极大极小值算法,将棋盘可能的局势都列出来,第一层 min 代表当前我方可能的 Moves,第二层 max 代表对方可能的 Moves,第三层 min 代表当前我方可能的 Moves,以此类推,我们需要做的就是对这棵树进行一遍深度优先搜索,在 max 层我们选择下一层节点中较大值,在 min 层我们选择下一层节点中最小值,我这里用 Wiki 上的图可能会更加直观一些。
这里贴上一些我的代码
minmax() {
const switchType = (type: string) =>{
if (type === 'min') {
return 'max';
}
if (type === 'max') {
return 'min';
}
}
console.log(`target depth: ${this.targetDepth} current depth: ${this.depth}`);
console.log(`current leaf nodes: ${this.leafNodes().length}`);
if (this.depth >= this.targetDepth) {
return this.evaluate(this.fen);
}
if (this.type === 'max') {
let max = -Infinity;
for (let i = 0; i < this.leafNodes().length; i++) {
const childFen = this.leafNodes()[i];
const child = new Node(this.targetDepth, this.depth + 1, childFen, switchType(this.type));
const value = child.minmax();
if (value > max) {
max = value;
}
}
return max;
}
if (this.type === 'min') {
let min = Infinity;
for (let i = 0; i < this.leafNodes().length; i++) {
const childFen = this.leafNodes()[i];
const child = new Node(this.targetDepth, this.depth + 1, childFen, switchType(this.type));
const value = child.minmax();
if (value < min) {
min = value;
}
}
return min;
}
}
测试计算 3 层,所需要的时间,看这个时间是非常慢的,因为需要计算很多节点。
test('minmax', () => {
const node = new Node(3, 0, 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', 'max');
node.minmax();
})
PASS src/operations/ai.test.js (155.997 s)
✓ evaluate boards (37 ms)
✓ minmax (154670 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 158.036 s
Alpha-beta pruning (αβ剪枝)
为什么会有这个算法,因为 Minmax 算法需要遍历每一个节点,你的节点数量多了,自然也就慢了。
Q:所以在遇到这个问题的时候,你能想到的是什么❓
A: 减少节点数量,对咯。所以就会有下面这个图。
有很多节点是不需要被遍历的,那么我们就需要从这棵树上面修剪掉,对代码稍微的进行一些改造
if (this.type === 'max') {
// max node edit alpha
for (let i = 0; i < this.leafNodes().length; i++) {
const [childFen, move] = this.leafNodes()[i];
const child = new ABPruningNode(this.targetDepth, this.depth + 1, childFen, this.switchType(this.type), this.alpha, this.beta);
const childValue = child.minmaxab();
if (childValue > this.alpha) {
this.chosenMove = move;
this.alpha = childValue;
}
// this.alpha = Math.max(this.alpha, value);
if (this.alpha >= this.beta) {
break;
}
}
return this.alpha;
}
PASS src/operations/ai.test.js (183.808 s)
✓ minmax (152114 ms)
✓ minmax with pruning (29636 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 185.226 s
Ran all test suites related to changed files.
从以上结果来看,剪枝之后的计算速度大幅的提高,因为需要计算的节点数量变得更少了。
Negamax
这个其实和 Minmax 算法类似,不过这个算法会更加简单,写起来也更加方便。
function negamax(node, depth, color) is
if depth = 0 or node is a terminal node then
return color × the heuristic value of node
value := −∞
for each child of node do
value := max(value, −negamax(child, depth − 1, −color))
return value