Js写五子棋
对半 人气:0这里的五子棋只做一些基础的功能,对于相对专业的规则不做处理。
那么该五子棋实现的规则和功能如下:
- 整体功能采用canvas实现
- 行列都规定 20 个数量,那么棋子的行列数量是 20 + 1
- 棋盘数据采用稀疏数组格式
- 棋子:0 为黑色,1 为白色
- 可以悔棋
- 胜负结束判断
棋盘绘制
<template> <div class="gobang"> <canvas id="my-canvas" ref="canvasRef" width="640" height="640" @click="canvasClick"> </canvas> </div> </template> <script lang="ts" setup> type GobangData = (0 | 1 | undefined)[][] /* 一些常量 */ // canvas dom 元素 const canvasRef = ref<InstanceType<typeof HTMLCanvasElement>>() // 行列数 const rcs = 20 // 行列的间隔距离 const gap = 30 // 棋子的半径 const radius = 12 // 棋盘的边距 const padding = 20 // 是否结束标记 const gameOver = ref(false) // 当前下棋方 let current = ref<0 | 1>(1) // canvas 的 2d 实例 let ctx: CanvasRenderingContext2D // 初始化棋盘数据 let data: GobangData = new Array(rcs + 1).fill(0).map(() => new Array(rcs + 1)) </script> <style lang="scss" scope> .gobang { width: 640px; margin: 0 auto; } .header { margin-bottom: 10px; display: flex; justify-content: space-between; .btns button { margin-left: 10px; padding: 0 5px; } } #my-canvas { background-color: #e6a23c; border-radius: 4px; } </style>
棋盘绘制
/** * 绘制棋盘 * @param ctx canvas的2d实例 * @param number 行列数 * @param gap 行列间隔距离 * @param padding 棋盘边距 */ const drawChessboard = ( ctx: CanvasRenderingContext2D, rcs: number, gap: number, padding: number ) => { ctx.beginPath() ctx.lineWidth = 1 // 行 for (let i = 0; i <= rcs; i++) { ctx.moveTo(padding + gap * i, padding) ctx.lineTo(padding + gap * i, padding + gap * rcs) } // 列 for (let i = 0; i <= rcs; i++) { ctx.moveTo(padding, padding + gap * i) ctx.lineTo(padding + gap * rcs, padding + gap * i) } ctx.strokeStyle = '#000' ctx.stroke() ctx.closePath() // 绘制中心圆点 ctx.beginPath() ctx.arc( padding + gap * rcs / 2, padding + gap * rcs / 2, 5, 0, 2 * Math.PI ) ctx.fillStyle = '#000' ctx.fill() ctx.closePath() }
棋子的绘制
我们需要在行列线条交接的地方需要放置棋子,所以我们每次绘制需要循环棋盘的数据,根据棋盘数据在指定的地方绘制棋子
/** * 绘制棋子,先循环列,再循环行 * @param ctx canvas的2d实例 * @param data 棋盘数据 * @param number 行列数 * @param gap 行列间隔距离 * @param padding 棋盘边距 * @param radius 棋子的半径 */ const drawPieces = ( ctx: CanvasRenderingContext2D, data: GobangData, gap: number, padding: number, radius = 12 ) => { const m = data.length, n = data[0].length for (let i = 0; i < m; i++) { const cj = i * gap + padding + 6 - padding const sj = padding + i * gap for (let j = 0; j < n; j++) { // 值为 undefined 时跳过 if (data[i][j] === undefined) { continue } const ci = j * gap + padding + 6 - padding const si = padding + j * gap if (!data[i][j]) { // 值为 1 时,绘制黑棋 drawBlackPieces( ctx, ci, cj, si, sj, radius ) } else { // 值为 0 时,绘制黑棋 drawWhitePieces( ctx, ci, cj, si, sj, radius ) } } } }
黑白子的绘制,只是颜色不一样
// 绘制白子 function drawWhitePieces( ctx: CanvasRenderingContext2D, ci: number, cj: number, si: number, sj: number, radius = 12 ) { ctx.beginPath() const lg2 = ctx.createRadialGradient( ci, cj, 5, ci, cj, 20 ) // 向圆形渐变上添加颜色 lg2.addColorStop(0.1, '#fff') lg2.addColorStop(0.9, '#ddd') ctx.fillStyle = lg2 ctx.arc( si, sj, radius, 0, 2 * Math.PI ) ctx.fill() ctx.closePath() } // 绘制黑子 function drawBlackPieces( ctx: CanvasRenderingContext2D, ci: number, cj: number, si: number, sj: number, radius = 12 ) { ctx.beginPath() const lg2 = ctx.createRadialGradient( ci, cj, 5, ci, cj, 20 ) // 向圆形渐变上添加颜色 lg2.addColorStop(0.1, '#666') lg2.addColorStop(0.9, '#000') ctx.fillStyle = lg2 ctx.arc( si, sj, radius, 0, 2 * Math.PI ) ctx.fill() ctx.closePath() }
其中 ci
和 cj
是用于棋子上渐变的坐标,si
和 sj
是用于棋子绘制的圆心坐标。
在点击 canvas 的时候获取相对于棋盘数据的坐标点
const canvasClick = (e: MouseEvent) => { if (gameOver.value) { return } const { offsetX, offsetY } = e const posi = getPostions( offsetX, offsetY, gap, padding, radius ) // 当前位置在放置棋子范围内且没有放置棋子 if (posi && !data[posi[0]][posi[1]]) { data[posi[0]][posi[1]] = current.value init() pushStack(data) const res = isOver(data) if (res) { gameOver.value = true setTimeout(() => { const msg = (Array.isArray(res) ? `${data[res[0]][res[1]] ? '白' : '黑'}方获胜!` : '平局!') alert('游戏结束,' + msg) }, 50) } } } /** * 根据点击的坐标来获取棋盘数据的坐标 * @param offsetX 相对于父级元素的 x => 列位置 * @param offsetY 相对于父级元素的 Y => 行位置 * @param gap 行列间隔距离 */ const getPostions = ( offsetX: number, offsetY: number, gap: number, padding: number, r = 12 ): [number, number] | false => { const x = Math.round((offsetY - padding) / gap) const y = Math.round((offsetX - padding) / gap) // x1, y1 为圆心坐标 const x1 = x * gap + padding, y1 = y * gap + padding const nr = Math.pow(Math.pow(x1 - offsetY, 2) + Math.pow(y1 - offsetX, 2), 0.5) if (nr <= r) { return [x, y] } return false }
这里来判断点击的当前位置是否是有效的,并且具体坐标的规则是:
- 首先需要获取当前点最靠近哪一个棋子的圆心坐标
- 然后因为棋子的半径是 12,所以点击的位置距离棋子圆心的距离不能超过 12
- 满足则返回具体坐标,不满足则返回 false
是否结束
游戏结束分为两种情况:
- 所有格子全部填满,平局
- 已有相同的 5 颗棋子连成一条线,判胜负
在每一次棋子放下之后,就需要判断一次是否结束,我们每次需要判断一个坐标点的八个方向是否有相同的 4 颗棋子连成一条线。但是我们是依照从左至右,从上往下的顺序来检查的,所以具体检查只需要四个方向即可。
/** * 判断是否结束 * 从当前点查询八个方向的连续5个位置是否能连城线 * 但是在具体的逻辑判断中,是从左往右,从上往下一次判断的, * 所以在真正的执行过程中,只需要判断4个方向即可 * 这里选择的四个方向是:右上、右、右下、下 * @param {GobangData} data 棋盘数据 */ const isOver = (data: GobangData) => { const m = data.length, n = data[0].length let nullCnt = m * n for (let i = 0; i < m; i++) { for (let j = 0; j < n; j++) { if (data[i][j] !== undefined) { nullCnt-- if (getPostionResult(data, i, j, m, n)) { return [i, j] } } } } // 是否所有格子都已已有棋子 return !nullCnt } /** * 判读当前坐标是否满足结束要求 * @param {GobangData} data 棋盘数据 * @param {number} x x 轴 * @param {number} y y 轴 * @param {number} m 最大行数 * @param {number} n 最大列数 * @returns {boolean} */ function getPostionResult( data: GobangData, x: number, y: number, m: number, n: number ) { // 右上 右 右下 下 const ds = [[-1, 1], [0, 1], [1, 1], [1, 0]] const val = data[x][y] for (let i = 0; i < ds.length; i++) { const [dx, dy] = ds[i] let nx = x, ny = y, flag = true for (let i = 0; i < 4; i++) { nx += dx ny += dy // 是否是有效坐标,且值是否一样 if (!(nx >= 0 && nx < m && ny >= 0 && ny < n) || data[nx][ny] !== val) { flag = false break } } // 已有 5 颗连成一条线 if (flag) { return true } } return false }
关于是否结束的优化
是否结束还有一个优化的点,就是我们不需要判断所有坐标点是否满足,我们只需要判断最后一个放置棋子的点是否满足结束条件,但是如果只判断单个点的话,我们需要判断这个点的八个方向,所以可以优化下:
// 右上 左下 右 左 右下 左上 下 上 const ds = [[[-1, 1], [1, -1]], [[0, 1], [0, -1]], [[1, 1], [-1, -1]], [[1, 0], [-1, 0]]] /** * 判读当前坐标是否满足结束要求 * @param {GobangData} data 棋盘数据 * @param {number} x x 轴 * @param {number} y y 轴 * @param {number} m 最大行数 * @param {number} n 最大列数 * @returns {boolean} */ function getPostionResult( data: GobangData, x: number, y: number, m: number, n: number ) { const val = data[x][y] for (let i = 0; i < ds.length; i++) { const [[lx, ly], [rx, ry]] = ds[i] let nx = x, ny = y, cnt = 1 for (let j = 0; j < 4; j++) { nx += lx ny += ly if (!(nx >= 0 && nx < m && ny >= 0 && ny < n) || data[nx][ny] !== val) { break } cnt++ } nx = x ny = y for (let j = 0; j < 4; j++) { nx += rx ny += ry if (!(nx >= 0 && nx < m && ny >= 0 && ny < n) || data[nx][ny] !== val) { break } cnt++ } if (cnt >= 5) { return true } } return false } /** * 判断是否结束 * 从当前点查询八个方向的连续5个位置是否能连城线 * 所有格子是否全部填满 * 最后下棋的坐标是否连城线 * @param {GobangData} data 棋盘数据 * @param {[number, number]} posi 最后一个是否满足结束的坐标点 */ export const isOver = (data: GobangData, posi: [number, number]) => { const m = data.length, n = data[0].length let nullCnt = m * n // 先判断最后一个点是否满足结束 if (getPostionResult(data, posi[0], posi[1], m, n)) { return posi } for (let i = 0; i < m; i++) { for (let j = 0; j < n; j++) { if (data[i][j] !== undefined) { nullCnt-- } } } return !nullCnt }
悔棋功能
悔棋,也就是撤销功能,在放子的时候,保存当前的棋盘数据的快照,在悔棋的时候,拿到前一个快照的数据渲染出来。在做数据深拷贝的时候,用 JSON 的字符串解析方法,和 lodash 的深拷贝方法,都会讲原稀疏数组的空值都会填满,会破坏稀疏数组的结构定义,所以就自己根据场景写了一个拷贝方法:
// 深拷贝稀疏数组 function cloneDeep<T extends GobangData>(data: T):T { const m = data.length, n = data[0].length const res = new Array(m).fill(0).map(() => new Array(n)) as T for (let i = 0; i < m; i++) { for (let j = 0; j < n; j++) { if (data[i][j] !== undefined) { res[i][j] = data[i][j] } } } return res } // 缓存 const cacheData: GobangData[] = [cloneDeep<GobangData>(data)] const cacheIndex = ref(0) const pushStack = (data: GobangData) => { cacheData.push(cloneDeep<GobangData>(data)) cacheIndex.value++ } const popStack = () => { if (cacheIndex.value && !gameOver.value) { data = cloneDeep(cacheData[--cacheIndex.value]) cacheData.length = cacheIndex.value + 1 init() } }
到这里,一个简单的五子棋就完成了。
总结
加载全部内容