BoardGame.io 五子棋(一)

太长不看:直接点击这里查看代码

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 管理,无法修改。除 Gctx 外,其它参数你可自由定义,它们将被视为执行该动作的额外参数。

五子棋游戏中,我们只有一个动作,就是落子。且把它命名为 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,甚至添加在线对局功能。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值