react实现五子棋

1,469 阅读3分钟

目标: 用react实现一个五子棋小游戏

环境: node 14.17.3

参考项目: React官网 三子棋项目: Tic Tac Toe (codepen.io)

具体实现

写法思路

  • 在组件挂载完成时绘制棋盘 ==> 先绘制棋盘
  • 在棋盘上点击时绘制棋子 ==> 绘制棋子
  • 将用户在棋盘上的落子保存到数组中,判断这次落子是否能够获胜,不能则继续游戏 ==> 实现五子棋核心算法和状态管理
  • 当有一方获胜时绘制取胜路径 ==> 当有一方获胜,绘制获胜路径

主要实现的功能

  • 五子棋获胜逻辑 ==> 每次落子的时候都判断是否有相同颜色的棋子在它周围(即水平、垂直、左上到右下,右下到左上这四个方向),当颜色相同的棋子在一个方向上相连达到五颗即取得游戏胜利
  • 游戏结束时选择重置时初始化组件状态并清除绘制棋子的画布内容

项目仓库地址 react_wuziqi

项目在线预览:Document (cloudendpoint.cn)

1. 文件结构

newGame // 组件根文件夹

​ |----const.js

​ |----untils.js

​ |----index.jsx

​ |----store //这里是文件夹

​ |---- |----reducer.js

2. 核心代码

 // 绘制棋盘
function init() {
        let canvas = canvasRef.current;
        let ctx = canvas.getContext('2d');
        ctx.strokeStyle = "#999"
        for (let x = 0; x <= COLUMN; x++) {
            ctx.moveTo(x * BASE_WIDTH, 0);
            ctx.lineTo(x * BASE_WIDTH, height);
            ctx.stroke();
        }
        for (let y = 0; y <= ROW; y++) {
            ctx.moveTo(0, y * BASE_HEIGHT);
            ctx.lineTo(width, y * BASE_HEIGHT);
            ctx.stroke();
        }
        var [realX, realY] = getRealCoordinate(400, 400)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(240, 240)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(240, 560)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(560, 560)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(560, 240)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        ctx.fillStyle = "#000";
        ctx.fill();
        ctx.stroke();
        ctx.closePath();
    }

// 落子
    function select(e) {
        if (gameOver === true) {
            if (gameOver && confirm(`游戏已结束,获胜者是${user === 1 ? "X" : "O"},是否重置游戏?`)) {
                chessRef.current.getContext('2d').clearRect(0, 0, playWidth, playWidth);
                dispatch({ type: RESET });
                setIsBlack(true);
            }
            return;
        }
        const [realX, realY, realXIndex, realYIndex] = getRealCoordinate(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
        if (realXIndex >= 0 && realYIndex >= 0 && realXIndex <= 20 && realYIndex <= 20 && history[realXIndex][realYIndex] === 0) {
            const ctx = chessRef.current.getContext('2d');
            const gradient = ctx.createRadialGradient(realX, realY, RADIUS, realX - 5, realY - 5, 0);
            if (isBlack) {
                gradient.addColorStop(0, '#0a0a0a');
                gradient.addColorStop(1, '#636766');
            } else {
                gradient.addColorStop(0, '#d1d1d1');
                gradient.addColorStop(1, '#f9f9f9');
            }
            getCircle(ctx, realX, realY, RADIUS, gradient);

            ctx.closePath();
            setIsBlack(!isBlack);
            dispatch({ type: SELECT, realXIndex, realYIndex, user: isBlack ? 1 : 2 })
        };
    }
// 3. 擦除棋子画布上的内容
chessRef.current.getContext('2d').clearRect(0, 0, playWidth, playHeight);

3. 整体代码

① NewGame.jsx

// NewGame.jsx

import React, { useRef, useEffect, useState, useReducer } from 'react'
import { getRealCoordinate, getCircle } from "./util";
import { RESET, SELECT, BASE_WIDTH, BASE_HEIGHT, CIRCLE, RADIUS, COLUMN, ROW } from './const';

import { reducer, initialState } from './store/reduce';


export default function NewGame(props) {

    const { width = 800, height = 800 } = props;
    const { playWidth = 880, playHeight = 880 } = props;

    let [isBlack, setIsBlack] = useState(true);
    const [state, dispatch] = useReducer(reducer, initialState)

    let canvasRef = useRef(null);
    let chessRef = useRef(null);
    let routeToWinRef = useRef(null);

    const { history, result, gameOver } = state;

    function init() { // 绘制棋盘
        let canvas = canvasRef.current;
        let ctx = canvas.getContext('2d');
        ctx.strokeStyle = "#999"
        for (let x = 0; x <= COLUMN; x++) {
            ctx.moveTo(x * BASE_WIDTH, 0);
            ctx.lineTo(x * BASE_WIDTH, height);
            ctx.stroke();
        }
        for (let y = 0; y <= ROW; y++) {
            ctx.moveTo(0, y * BASE_HEIGHT);
            ctx.lineTo(width, y * BASE_HEIGHT);
            ctx.stroke();
        }
        var [realX, realY] = getRealCoordinate(400, 400)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(240, 240)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(240, 560)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(560, 560)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        var [realX, realY] = getRealCoordinate(560, 240)
        ctx.moveTo(realX, realY);
        ctx.arc(realX, realY, 5, 0, CIRCLE);

        ctx.fillStyle = "#000";
        ctx.fill();
        ctx.stroke();
        ctx.closePath();
    }

    function select(e) {// 落子
        if (gameOver === true) {
            if (gameOver && confirm(`游戏已结束,获胜者是${user === 1 ? "X" : "O"},是否重置游戏?`)) {
                chessRef.current.getContext('2d').clearRect(0, 0, playWidth, playWidth);
                dispatch({ type: RESET });
                setIsBlack(true);
            }
            return;
        }
        const [realX, realY, realXIndex, realYIndex] = getRealCoordinate(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
        if (realXIndex >= 0 && realYIndex >= 0 && realXIndex <= 20 && realYIndex <= 20 && history[realXIndex][realYIndex] === 0) {
            const ctx = chessRef.current.getContext('2d');
            const gradient = ctx.createRadialGradient(realX, realY, RADIUS, realX - 5, realY - 5, 0);
            if (isBlack) {
                gradient.addColorStop(0, '#0a0a0a');
                gradient.addColorStop(1, '#636766');
            } else {
                gradient.addColorStop(0, '#d1d1d1');
                gradient.addColorStop(1, '#f9f9f9');
            }
            getCircle(ctx, realX, realY, RADIUS, gradient);

            ctx.closePath();
            setIsBlack(!isBlack);
            dispatch({ type: SELECT, realXIndex, realYIndex, user: isBlack ? 1 : 2 })
        };
    }

    function reset() { // 重置游戏
            chessRef.current.getContext('2d').clearRect(0, 0, playWidth, playHeight);
            dispatch({ type: RESET });
            setIsBlack(true);
    }
    function finishReset() {
        // 有一方获胜时选择重置游戏
        if (confirm(`游戏已结束,获胜者是${state.user === 1 ? "白棋" : "黑棋"},是否重置游戏?`)) {
            reset()
        }
    }
    useEffect(() => {
        init(); //初始化绘制棋盘
    }, []);

    useEffect(() => {
        if (gameOver) {
            /**  计算获胜路线的起点和终点 start           */
            let start = result.sort((a, b) => a.x - b.x)[0];
            let end = result.sort((a, b) => a.x - b.x)[4];

            let startX = (start.x + 1) * BASE_WIDTH;
            let endX = (end.x + 1) * BASE_WIDTH;

            let startY = (start.y + 1) * BASE_HEIGHT;
            let endY = (end.y + 1) * BASE_HEIGHT;
            /**  计算获胜路线的起点和终点 end           */

            /** 绘制获胜路线 start */
            const ctx = routeToWinRef.current.getContext('2d');

            ctx.strokeStyle = "#DB7D74";
            ctx.lineWidth = 5;
            ctx.moveTo(startX, startY);
            ctx.lineTo(endX, endY);
            ctx.stroke();
            /** 绘制获胜路线 end */

            setTimeout(finishReset, 500);
        }
    }, [gameOver === true]);

    return (
        <div style={{
            display: "flex",
            justifyContent: "center",
            alignItems: "center"
        }}>

            <div>
                <div style={{
                    position: "relative",
                    cursor: "pointer",
                }}>

                    {/* 第一个canvas当作棋盘背景使用 */}
                    <canvas ref={canvasRef} width={width} height={height}
                        style={{
                            border: "1px solid #999",
                            zIndex: "-1",
                            position: "absolute",
                            margin: "40px"
                        }}
                    >
                    </canvas>
                    {/* 第二个canvas用来绘制棋子 */}
                    <canvas ref={chessRef} width={playWidth} height={playHeight}
                        style={{
                            zIndex: "1000",
                            border: "1px solid red",
                        }}
                        onClick={select}
                    >
                    </canvas>
                    {/* 第三个canvas用来绘制获胜的路径 */}
                    {
                        gameOver && <canvas ref={routeToWinRef} width={playWidth}
                            height={playHeight}
                            style={{
                                zIndex: "1",
                                position: "absolute",
                                left: 0,
                                top: 0
                            }}
                            onClick={finishReset}
                        ></canvas>
                    }
                </div>
            </div>
            <div style={{
                position: "absolute",
                right: "200px",
                width: "200px",
                height: "100px",
                display: "flex",
                flexDirection: "column",
                justifyContent: "space-around",
                alignItems: "flex-start",
                border: "1px solid black",
                padding: "20px"
            }}>
                <div>
                    {
                        gameOver ? `游戏已结束,获胜者是${isBlack ? "白棋" : "黑棋"}` : "棋局仍在进行中"
                    }
                </div>
                <div>当前执棋者&nbsp;{isBlack ? "黑棋" : "白棋"}</div>
                <button onClick={reset}>重置游戏</button>
            </div>
        </div>
    )
}

② const.js



   // const.js
   
   const ROW = 20;
   const COLUMN = 20;
   const RADIUS = 15;
   const BASE_WIDTH = 40;
   const BASE_HEIGHT = 40;
   const CIRCLE = 2 * Math.PI;
   const RESET = "RESET";
   const SELECT = "SELECT";
   const DIRECTION = {
       HORIZONTAL: 1,
       VERTICAL: 2,
       LEFT_OBLIQUE: 3,
       RIGHT_OBLIQUE: 4
   };
   export {
       ROW,
       COLUMN,
       DIRECTION,
       RESET,
       SELECT,
       BASE_WIDTH, 
       BASE_HEIGHT, 
       CIRCLE,
       RADIUS
   };
   

③ util.js


   // util.js
   
   /**
    * 游戏初始数据
    */
   import { 
       DIRECTION,
       BASE_WIDTH, 
       BASE_HEIGHT, 
       CIRCLE,
       RADIUS } from "./const";
   export function setStaticState(result) {
       if (result && result.length === 5) {
           return {
               gameOver: true
           }
       } else {
           return {
               gameOver: false
           }
       }
   }
   
   export function checkIsWin(arr, x, y) {
       let target = arr[x][y],
           rowLength = arr.length, // 棋盘高度
           colLength = arr[0].length, // 棋盘宽度
           startNode = { x, y },
           nodeList;
       /**
        * 
        * @param {*} node 
        * @returns 检测结点是否与检测目标值target相同
        */
       function check(node) {
           /**
            * 检测是否越界
            */
           if (node.x >= rowLength || node.x < 0 || node.y >= colLength || node.y < 0) {
               return false;
           }
           if (arr[node.x][node.y] === target) {
               return true;
           }
           return false;
       }
   
       for (let i = 1; i <= 4; i++) {
           nodeList = [startNode];
           let left = startNode,
               right = startNode,
               leftVal = true,
               rightVal = true;
   
           // 从当前节点出发,左右或者上下同时检测,如果值与目标检测节点值target相同则nodeList的长度加一
           while (leftVal || rightVal) {
               if (leftVal) {
                   left = getCoordinate(i, left, -1);
                   leftVal = check(left) && nodeList.push(left);
               }
               if (rightVal) {
                   right = getCoordinate(i, right, 1);
                   rightVal = check(right) && nodeList.push(right);
               }
               // nodeList的长度是五即取得胜利
               if (nodeList.length === 5) {
                   return nodeList;
               }
           }
       }
       return nodeList;
   };
   /**
    * 
    * @param {*} direct  horizontal: 1, vertical: 2, leftOblique: 3, rightOblique: 4 水平方向,垂直方向,左下到右上,右下到左上,
    * @param {*} node 
    * @param {*} tag 1 向右 -1 向左
    * @returns 根据tag的值对node的坐标值进行处理后并返回新的坐标值
    */
   export function getCoordinate(direct, node, tag) {
       let newNode;
       let {
           HORIZONTAL,
           VERTICAL,
           LEFT_OBLIQUE,
           RIGHT_OBLIQUE } = DIRECTION;
       switch (direct) {
           case HORIZONTAL:
               newNode = {
                   x: node.x,
                   y: node.y + tag
               };
               break;
           case VERTICAL:
               newNode = {
                   x: node.x + tag,
                   y: node.y
               };
               break;
           case LEFT_OBLIQUE:
               newNode = {
                   x: node.x + tag,
                   y: node.y + tag
               };
               break;
           case RIGHT_OBLIQUE:
               newNode = {
                   x: node.x - tag,
                   y: node.y + tag
               };
               break;
           default:
               newNode = {
                   x: -1,
                   y: -1
               };
       }
       return newNode;
   }
   
   // 创建带初始值的二维数组
   export function get2DArray(row, column, initialValue) {
       let result = [];
       for (let i = 0; i <= row; i++) {
           let rowArray = [];
           for (let j = 0; j <= column; j++) {
               rowArray[j] = initialValue;
           }
           result.push(rowArray);
       }
       return result;
   }
   //计算棋子在画布上的真实位置
   export function getRealCoordinate(x, y) { 
       let realX = Math.round(x / BASE_WIDTH) * (BASE_WIDTH);
       let realY = Math.round(y / BASE_HEIGHT) * (BASE_HEIGHT);
       let realXIndex = Math.round((x - 40) / (BASE_WIDTH));
       let realYIndex = Math.round((y - 40) / (BASE_HEIGHT));
       return [realX, realY, realXIndex, realYIndex];
   }
    //画圆
   export function getCircle(ctx, x, y, r = RADIUS, fillStyle) {
       ctx.beginPath();
       ctx.arc(x, y, r, 0, CIRCLE);
       if (fillStyle) {
           ctx.fillStyle = fillStyle;
           ctx.fill();
       } else {
           ctx.stroke();
       }
       ctx.closePath();
   }

④ store/reducer.js


   
   // store/reducer.js
   
   import { checkIsWin, setStaticState, get2DArray } from "../util";
   import {
       ROW,
       COLUMN,
       RESET,
       SELECT,
   } from '../const';
   export const initialState = {
       history: get2DArray(ROW, COLUMN, 0),
       user: 0,
       result: [],
       gameOver: false
   }
   
   export function reducer(state, action) {
       switch (action.type) {
           case SELECT:
               let { realXIndex, realYIndex, user } = action;
               state.history[realXIndex][realYIndex] = user;
               state.result = checkIsWin(state.history, realXIndex, realYIndex);
               state.gameOver = setStaticState(state.result).gameOver; // 每次都对游戏结果进行检查
               return state;
           case RESET:
               state = {
                   history: get2DArray(ROW, COLUMN, 0),
                   user: 0,
                   result: [],
                   gameOver: false
               }
               return state;
           default:
               throw new Error(`触发${action.type}时发生错误`);
       }
   }