太长不看:直接点击这里查看代码
BoardGame.io 是一个专门为回合制游戏打造的游戏引擎。无需一行网络或存储相关的代码,只需要编写简单函数描述游戏动作如何影响游戏状态,即可自动帮你生成一个支持多人在线的完整游戏。它支持回合制游戏的方方面面,比如状态管理、多人在线、AI、游戏进程或回合管理、游戏大厅等诸多功能。今天我们就用它结合 React 制作一个五子棋游戏。
准备
首先,我们准备一个 React 项目,并添加 boadgame.io 依赖。
npx create-react-app gomoku
cd gomoku
npm install boardgame.io
定义游戏
接下来,我们要定义游戏。定义游戏相当于告诉 boardgame.io 游戏是怎么玩的。由于引擎会管理好当前玩家、游戏是否结束等这些状态,对于五子棋游戏来说,只剩下局面信息需要定义了。通过创建一个满足特定接口的对象,即可定义一个游戏。这些接口当中,setup
函数是起点,它负责创建游戏状态 G
。而 moves
则定义了此游戏中有多少种可以执行的动作。
动作实际是一个函数,它接收当前状态 G
作为参数,并对它进行修改,使它成为新的状态。动作函数还接收另外一个参数 ctx
, 它包含当前玩家、当前回合等这些信息,由 boardgame.io 管理,无法修改。除 G
和 ctx
外,其它参数你可自由定义,它们将被视为执行该动作的额外参数。
五子棋游戏中,我们只有一个动作,就是落子。且把它命名为 putStone
,它需要一个 ID 参数告诉它该在哪里下子。
创建 src/Game.js
文件,并添加以下内容:
export const Gomoku = {
setup: () => ({ stones: Array(15*15).fill(0) }),
moves: {
putStone: (G, ctx, id) => {
G.stones[id] = [1,-1][ctx.currentPlayer];
},
},
};
定义客户端
客户端相当于一个可以玩游戏,只不过只能通过 API 玩。将 src/App.js
内容替换为以下内容:
import { Client } from 'boardgame.io/react';
import { Gomoku } from './Game';
const App = Client({ game: Gomoku});
export default App;
若此时运行 npm start
,会看到一个 UI,这是 boardgame.io 渲染的 Debug 面板。当前状态下,使用这个 UI 下虽然也可以进行游戏,但玩起来相当费劲,就不介绍了。
Debug 面板在生产环境构建时 (
NODE_ENV='production'
)会被自动去除,也可以在Client
的配置中添加debug: false
关闭。
改善游戏逻辑
着法验证
目前为止,若玩家对已经落子的位置调用 putStone
, 则那个位置的棋子会被覆盖。这挺扯的,需要防止。
要让 boardgame.io 就能知道此着法是无效的,需返回一个从 boardgame.io 中引入的特别常量:
import { INVALID_MOVE } from 'boardgame.io/core';
现在,我们在 putStone
中验证着法并返回 INVALID_MOVE
:
putStone: (G, ctx, id) => {
if (G.stones[id] !== 0) {
return INVALID_MOVE;
}
G.stones[id] = [1,-1][ctx.currentPlayer];
}
回合管理
不同的游戏,玩家在一回合可以进行的动作数量可能不同,结束回合的条件也不同。有的游戏一回合可以进行多个动作,有的一回合一个动作。五子棋就是一回合一个动作的游戏。在 Debug 面板中,我们可以点击 endTurn
手动结束回合。其实,客户端代码也可以执行完动作后自动结束回合。
在 boardgame.io 中有多种方法管理回合,使用 moveLimit
就是其中之一。下面我们就使用这种方法让引擎自动帮我们结束当前回合:
export const Gomoku = {
setup: () => { /* ... */ },
turn: {
moveLimit: 1,
},
moves: { /* ... */ },
};
胜利条件
我们的五子棋游戏定义得差不多了,只差最后一环:胜利局面判断。
首先,我们先加几个函数:
/**
* 检查一条线上某一方是否成 5
* @param {number[]} stones
* @param {number} clr
* @param {number} start
* @param {number} end
* @param {number} stride
*/
function checkWinnerByLine(stones, clr, start, end, stride) {
let cnt = 0;
for (; cnt < 5 && start !== end; start += stride) {
if (stones[start] === clr) cnt++;
else cnt = 0;
}
return cnt >= 5;
}
/**
* 检查某位玩家是否获胜
* @param {number[]} stones
* @param {number} clr
*/
function isVictory(stones, clr) {
const boardSize = 15;
let x = 0,
y = 0;
let start = 0,
end = 0,
stride = 1;
// horizontal
stride = 1;
start = 0;
end = start + boardSize;
for (y = 0; y < boardSize; y++) {
let win= checkWinnerByLine(stones, clr, start, end, stride);
if (win) return true;
start += boardSize;
end += boardSize;
}
// vertical
stride = boardSize;
start = 0;
end = start + stride * boardSize;
for (x = 0; x < boardSize; x++) {
let win= checkWinnerByLine(stones, clr, start, end, stride);
if (win) return true;
start += 1;
end += 1;
}
// major diag
stride = boardSize + 1;
start = 0;
end = start + stride * boardSize;
for (x = 0; x < boardSize - 4; x++) {
let win= checkWinnerByLine(stones, clr, start, end, stride);
if (win) return true;
start += 1;
end -= boardSize;
}
start = boardSize;
end = start + stride * (boardSize - 1);
for (y = 1; y < boardSize - 4; y++) {
let win= checkWinnerByLine(stones, clr, start, end, stride);
if (win) return true;
start += boardSize;
end -= 1;
}
// secondary diag
stride = boardSize - 1;
start = 4;
end = start + stride * 5;
for (x = 4; x < boardSize; x++) {
let win= checkWinnerByLine(stones, clr, start, end, stride);
if (win) return true;
start += 1;
end += boardSize;
}
start = 2 * boardSize - 1;
end = start + stride * (boardSize - 1);
for (y = 1; y < boardSize - 4; y++) {
let win= checkWinnerByLine(stones, clr, start, end, stride);
if (win) return true;
start += boardSize;
end += 1;
}
return false;
}
/**
* @param {number[]} stones
*/
function isDraw(stones) {
return stones.every(s => s !== 0);
}
这几个函数用来判断胜利局面和平局。其中,胜利局面判断方式是四个方向逐行扫描 “成 5 ” 棋型。由于现在我们不知道上次落子点,所以无法做局部扫描。
接下来,我们添加 endIf
函数到我们的游戏定义中。此函数会在每次游戏状态更新后被调用,引擎借此知道游戏是否结束。
export const Gomoku = {
// setup, moves, etc.
endIf: (G, ctx) => {
if (isVictory(G.stones, [1, -1][ctx.currentPlayer])) {
return { winner: ctx.currentPlayer };
}
if (isDraw(G.stones)) {
return { draw: true };
}
},
};
若游戏未结束,
endIf
必须返回undefined
或者null
,其它值都会视为游戏结束。若游戏结束,函数返回值将赋值给ctx.gameOver
。
制作棋盘
到这里,整个游戏只差 UI 了,有了 UI 我们就可以使用鼠标点击棋盘进行游戏了。UI 可以先简单做,直接把游戏状态 G
转换成可以点击的格子即可。
创建 src/Board.js
文件,并添加以下内容:
export function GomokuBoard({ G, ctx, moves }) {
function handleClick(id) {
moves.putStone(id);
}
let winner = "";
if (ctx.gameover) {
winner =
ctx.gameover.winner !== undefined ? (
<div id="winner">Winner: {ctx.gameover.winner}</div>
) : (
<div id="winner">Draw!</div>
);
}
const cellStyle = {
border: "1px solid #555",
width: "50px",
height: "50px",
lineHeight: "50px",
textAlign: "center"
};
let tbody = [];
for (let i = 0; i < BOARD_SIZE; i++) {
let cells = [];
for (let j = 0; j < BOARD_SIZE; j++) {
const id = BOARD_SIZE * i + j;
cells.push(
<td style={cellStyle} key={id} onClick={() => handleClick(id)}>
{G.stones[id]}
</td>
);
}
tbody.push(<tr key={i}>{cells}</tr>);
}
return (
<div>
<table id="board">
<tbody>{tbody}</tbody>
</table>
{winner}
</div>
);
}
渲染效果如下
在使用 CodeSandbox.io 过程中我发现,使用 BoardGame.io 的 react 组件会出现某个模块找不到的情况,而使用非 react 的模块时不会出现。可以把 src/App.js
替换为如下内容解决:
//import { Client } from "boardgame.io/react";
import React, { useEffect, useMemo, useState } from "react";
import { Client } from "boardgame.io/client";
import { Gomoku } from "./Game";
import { GomokuBoard } from "./Board";
//const App = Client({ game: Gomoku });
function App() {
const client = useMemo(() => Client({ game: Gomoku }), []);
const [boardProps, setBoardProps] = useState(client.getInitialState());
useEffect(() => {
let c = client;
c.subscribe((s) => {
setBoardProps({ ...s });
});
c.start();
return () => c.stop();
}, [client]);
return <GomokuBoard {...boardProps} moves={client.moves} />;
}
export default App;
添加机器人
前面提到 boardgame.io 支持 AI,只需要告诉引擎当前状态下的可行动作有哪些,AI 就会尝试搜索出最可能获胜的那个动作。
要添加 AI,需要在我们的游戏定义中添加 ai
配置项。其中 enumerate
函数应该返回当前状态 G
的所有可行动作(数组表示)。以五子棋为例,每个空白点位都可以执行一个 putStone
动作,函数就应该返回每个空白点对应的 putStone
动作。
export const Gomoku = {
// setup, turn, moves, endIf ...
ai: {
enumerate: (G, ctx) => {
let moves = [];
for (let i = 0; i < G.stones.length; i++) {
if (G.stones[i] === 0) {
moves.push({ move: "putStone", args: [i] });
}
}
return moves;
}
}
};
大功造成。现在打开 Debug 面板中的 AI 页,就可以使用 AI 了:
play
用来让 AI 执行一个动作。在五子棋中,就是走一步棋simulate
用来让 AI 自己完成一局游戏。相当于自我对局
美化棋盘
虽然已经做了一个棋盘,不过表格做的棋盘看起来不太美观,接下来我们美化一下。
wgo.js 是专门为制作网页围棋而开发的库,好在它的棋盘部分通用性和扩展性很好,完全能用于五子棋棋盘。它功能丰富,支持多种棋子渲染样式,还拥有主题、局部棋盘、标记等功能。接下来,我们就使用它的默认主题,制作一个简单的棋盘。
首先,添加 wgo 依赖项
npm install wgo
然后,更新 src/Board.js
import React, { useEffect, useMemo, useRef } from "react";
import { BOARD_SIZE } from "./Consts";
import { FieldBoardObject, SVGBoard } from "wgo";
export function GomokuBoard({ G, ctx, moves }) {
const boardContainerRef = useRef();
const cachedStonesRef = useRef([]);
const board = useMemo(() => {
return new SVGBoard(document.createElement("div"), {
size: BOARD_SIZE,
width: 500,
heith: 500,
coordinates: true
});
}, []);
useEffect(() => {
const elem = board.element;
boardContainerRef.current.appendChild(elem);
return () => {
boardContainerRef.current.removeChild(elem);
};
}, [board]);
useEffect(() => {
const handler = (ev, pos) => {
let id = pos.y * BOARD_SIZE + pos.x;
moves.putStone(id);
};
board.on("click", handler);
return () => {
board.off("click", handler);
};
}, [board, moves]);
if (cachedStonesRef.current !== G.stones) {
const cachedStones = cachedStonesRef.current;
for (let i = 0; i < G.stones.length; i++) {
if (cachedStones[i] === G.stones[i]) continue;
let x = i % BOARD_SIZE;
let y = Math.floor(i / BOARD_SIZE);
if (cachedStones[i] !== 0) {
board.removeObjectsAt(x, y);
}
if (G.stones[i] === 1) {
board.addObject(new FieldBoardObject("B", x, y));
} else if (G.stones[i] === -1) {
board.addObject(new FieldBoardObject("W", x, y));
} else {
board.removeObjectsAt(x, y);
}
}
cachedStonesRef.current = G.stones;
}
let winner = "";
if (ctx.gameover) {
winner =
ctx.gameover.winner !== undefined ? (
<div id="winner">Winner: {ctx.gameover.winner}</div>
) : (
<div id="winner">Draw!</div>
);
}
return (
<div>
<div ref={boardContainerRef} />
{winner}
</div>
);
}
修改后的棋盘如下图,好看多了。
查看效果
wgo.js 目前的文档还是旧版本的,旧版本采用 Canvas 渲染,且只能加载到全局名字空间。新的 3.0 版本支持 SVG 和 UMD 模块,但还处在 alpha 阶段,没有文档和示例代码,需要直接参照它的源码使用。
总结
本文介绍了如何使用 boardgame.io 制作一个五子棋游戏,并添加了简单的 AI 功能,还介绍了如何使用 wgo.js 制作美观的棋盘,也算是一个像模像样的五子棋游戏了。接下来,还可以再美化下界面,强化一下 AI,甚至添加在线对局功能。