目标: 用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>当前执棋者 {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}时发生错误`);
}
}