如何用WebGL撸一个五子棋

2,132 阅读6分钟

在线例子

今天来用WebGL撸一个五子棋,效果图如下:

如果对WebGL基础流程和概念不熟悉的同学可以看我的这篇文章

主要分为以下几个步骤:

  1. 初始化棋盘样式
  2. 拾取屏幕坐标并渲染在画布上
  3. 绘制圆形棋子
  4. 把画布上的坐标转化为棋盘的格子坐标
  5. 保存棋盘棋子状态并判断输赢

前置工作

辅助函数

为了计算方便引入MV.jsvec2vec4flatten函数,作用分别为存储2个数据,4个数据和转换为32位浮点值的连续数组。

着色器

顶点着色器颜色使用Uniforms全局变量获取,由Varyings可变量传入片元着色器

<script id="vertex-shader" type="x-shader/x-vertex">
  attribute vec4 vPosition;
    uniform vec4 vColor;
    varying vec4 fColor;
    void
    main() {
      gl_Position = vPosition;
      fColor = vColor;
    }
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
  precision mediump float;
  varying vec4 fColor;
  void main()
  {
    gl_FragColor = fColor;
  }
</script>

初始化棋盘样式

首先声明一些变量,把棋盘设置成20x20

let gl;
const canvasSize = Math.min(window.innerHeight, window.innerWidth);
// 棋盘分段数 20*20
const boardSegment = 20;
// 棋盘顶点数量,使用`gl.LINES`绘制棋盘格子的话。横纵各需21条线,每条线2个顶点共需21x4个顶点。
const boardVertexNumber = (boardSegment + 1) * 4;
// 棋盘顶点坐标
const boardVertex = [];
// 每个棋子顶点数量(分段数量),先设为1用一个点表示,之后渲染成圆形棋子的时候会分段成多个顶点
const chessSegment = 1;
// 最大棋子数
const maxChess = (boardSegment + 1)** 2;
// 最大顶点数,棋盘顶点+棋子顶点
const maxPoints = boardVertexNumber + maxChess * chessSegment;
// 顶点着色器Uniforms全局变量vColor的位置
let colorLoc;

获取棋盘顶点

裁剪坐标x和y轴取值范围都是[-1, 1],长度为2。分为20份,,共需21条线,每条线间隔2 / boardSegment = 0.1。画水平于x轴线时,x值固定,y值-1 + i * 0.2。画垂直线于x轴线时,y值固定,x值-1 + i * 0.2

function initBoard() {
  const rowLineNumber = boardSegment + 1;
  const columnLineNumber = boardSegment + 1;
  const rowHeight = 2 / boardSegment;
  const columnWidth = 2 / boardSegment;
  for (let i = 0; i < rowLineNumber + columnLineNumber; i++) {
    if (i < rowLineNumber) {
      boardVertex.push(
        vec2(-1.0, -1 + i * rowHeight),
        vec2(1.0, -1 + i * rowHeight),
      );
    } else {
      boardVertex.push(
        vec2(-1 + (i % rowLineNumber) * columnWidth, -1),
        vec2(-1 + (i % rowLineNumber) * columnWidth, 1),
      );
    }
  }
}

WebGL程序

下面开始WebGL部分

通常情况一次性绘制所有顶点的用法是bufferData()把数据直接传给buffer:

gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)

但是五子棋需要每次点击棋盘动态的传入数据,这里要说一下bufferData的另一种用法,需要先告诉缓冲区需要多大的空间,然后使用bufferSubData每次向里面传值:

gl.bufferData(gl.ARRAY_BUFFER, verticesSize, gl.STATIC_DRAW)
// 向缓冲区传值
gl.bufferSubData(
    gl.ARRAY_BUFFER,
    offset,
     new Float32Array(vertices),
  );

bufferSubData第二个参数表示以字节为单位指定开始数据替换的偏移量,类似splice(offset, 0, new Float32Array(vertices))

window.onload = function() {
  const canvas = document.getElementById("gl-canvas");
  canvas.width = canvasSize;
  canvas.height = canvasSize;
  gl = canvas.getContext("webgl");
  if (!gl) return false;
  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.clearColor(204 / 255, 161 / 255, 129 / 255, 1.0); //这里把画布颜色设置为类似木头的颜色
  
  // 着色程序
  const vertexShaderSource = document.getElementById("vertex-shader").text;
  const fragmentShaderSource = document.getElementById("fragment-shader").text;
  const program = createProgram(gl, vertexShaderSource, fragmentShaderSource);
  gl.useProgram(program);
  
  // 顶点
  const vBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    8 * (maxPoints), // 缓冲大小,每个顶点都是2个32位浮点数,共8个字节
    gl.STATIC_DRAW,
  );
  const vPosition = gl.getAttribLocation(program, "vPosition");
  gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
  gl.enableVertexAttribArray(vPosition);
  // 获取顶点着色器Uniforms全局变量vColor的位置
  colorLoc = gl.getUniformLocation(program, 'vColor')
  
  // 初始化棋盘
  initBoard(); // 获取棋盘顶点
  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer); // 绑定当前缓冲区
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, flatten(boardVertex)); // 向缓冲区传值
  render()
}

把清除画布,设置顶点颜色和绘制函数单独封装成render函数方便接下来复用。

function render() {
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniform4fv(colorLoc, vec4(0.0, 0.0, 0.0, 1.0)); // 棋盘网格为黑色
  gl.drawArrays(gl.LINES, 0, boardVertexNumber); // 使用gl.LINES,每两个点绘制成一条不相连的线段
}

这样棋盘的样式就绘制好了

拾取屏幕坐标并渲染在WebGL上

屏幕坐标转换为裁剪坐标

首先我们需要一个函数把鼠标拾取的屏幕坐标转换为WebGL需要的裁剪坐标,也就是

x轴取值范围[0, canvas.width]=>[-1, 1]

y轴取值范围[0, canvas.height]=>[1, -1]

x / width 取值范围是[0, 1](x / width) * 2 - 1范围则是[-1, 1], y轴和x类似,只不过屏幕坐标y轴正方和裁剪坐标y轴正方向相反,要取负数 1 - (y / height) * 2。下面是转换函数:

/**
 * 屏幕坐标转化为WebGL裁剪坐标
 * @param {Number} x 屏幕坐标x坐标
 * @param {Number} y 屏幕坐标y坐标
 * @param {Number} width 画布的宽
 * @param {Number} height 画布的高
 */
function vertex2Gl(x, y, width, height) {
  return {
    x: (x / width) * 2 - 1,
    y: 1 - (y / height) * 2,
  };
}

点击画布添加棋子

其次需要为canvas添加click事件

window.onload = function() {
    //...
    render()
+   canvas.addEventListener("click", evt => {
+       const { clientX: x, clientY: y } = evt;
+       const { width, height } = canvas;
+       addChess(vBuffer, x, y, width, height);
+    });    
    
}

每次点击画布执行添加棋子addChess()函数,需要执行bufferSubData()函数向缓冲区传递数据,因为要设置offset偏移量。所以我们需要一个变量记录当前渲染过棋子的顶点总数

// ...
let colorLoc;
+ //棋子顶点数
+ let chessVertexCount = 0;
// 添加棋子
function addChess(vBuffer, x, y, width, height) {
  // 获取裁裁剪坐标
  const { x: x_gl, y: y_gl } = vertex2Gl(x, y, width, height);
  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
  gl.bufferSubData(
    gl.ARRAY_BUFFER,
    (boardVertexNumber + chessVertexCount) * 8,  // offeset偏移量为 棋盘顶点加上已绘制棋子顶点的字节大小
    flatten(vec2(x_gl, y_gl)),
  );
  // 每次加上一个棋子顶点数量
  chessVertexCount += chessSegment;
  render();
}

render函数也需要添加绘制棋子的逻辑,循环执行drawArrays()函数,绘制的开始位置为棋盘顶点数量➕已绘制棋子顶点数量,每次绘制棋子顶点个数的数量

function render() {
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniform4fv(colorLoc, vec4(0.0, 0.0, 0.0, 1.0)); // 棋盘网格为黑色
  gl.drawArrays(gl.LINES, 0, boardVertexNumber); // 使用gl.LINES,每两个点绘制成一条不相连的线段
+  for (let i = 0; i < chessVertexCount; i += chessSegment) {
+    gl.drawArrays(gl.POINTS, boardVertexNumber + i, chessSegment);
+  }
}

这样点击画布就有棋子:

设置棋子样式

可以看到每个点都很小不容易看清: 我们可以使用顶点着色器gl_PointSize使点的尺寸变大些

<script id="vertex-shader" type="x-shader/x-vertex">
  attribute vec4 vPosition;
    uniform vec4 vColor;
    varying vec4 fColor;
    void
    main() {
+     gl_PointSize = 20.0;
      gl_Position = vPosition;
      fColor = vColor;
    }
</script>

这样就好多了

全是黑色也不行,需要一次黑一次白

这里用i / chessSegment表示当前绘制的第几个节点,可能有疑问chessSegment为1为什么要除一下,因为现在棋子用点表示只用一个顶点,下文换成圆渲染一个棋子会有好多顶点

function render() {
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniform4fv(colorLoc, vec4(0.0, 0.0, 0.0, 1.0)); // 棋盘网格为黑色
  gl.drawArrays(gl.LINES, 0, boardVertexNumber); // 使用gl.LINES,每两个点绘制成一条不相连的线段
  for (let i = 0; i < chessVertexCount; i += chessSegment) {
+    const color = i / chessSegment % 2 === 0 ? vec4(0.0, 0.0, 0.0, 1.0) : vec4(1.0, 1.0, 1.0, 1.0)
+    gl.uniform4fv(colorLoc, color);
   gl.drawArrays(gl.POINTS, boardVertexNumber + i, chessSegment);
  }
}

这样就就可以了

利用坐标绘制圆形棋子

那么怎么绘制一个圆呢?筛选了一边绘制类型,发现gl.TRIANGLE_FAN比较容易实现,我们只需要求出构成圆若干等距的顶点就可以了 怎么求构成圆的顶点坐标呢?在一个圆心在原点的圆中,圆上任意一坐标都可以利用半径和三角函数来求出,如下图:

点P的坐标为P(r * cos(θ), r * sin(θ))。因为棋子的坐标不是在原点,所以我们需要把顶点坐标加上圆心的坐标P(Ox + r * cos(θ), Oy + r * sin(θ))

我们暂时用20点构成一个圆。当然,点越多,渲染出来越接近圆。

- const chessSegment = 1;
+ const chessSegment = 20;

下面可以可以用循环得出每个点的角度,利用三角函数求出棋子的坐标了:

/**
 * 由棋子坐标得到棋子顶点坐标
 * @param {Number} x 棋子x坐标
 * @param {Number} y 棋子y坐标
 */
function vertexChess(x, y) {
  // 棋子半径是半个网格的长度
  const chessRadius = 2 / boardSegment / 2;
  const chessVertex = [];
  for (let i = 0; i < chessSegment; i++) {
    const x_chess =
      x + chessRadius * Math.cos(((Math.PI * 2) / chessSegment) * i);
    const y_chess =
      y + chessRadius * Math.sin(((Math.PI * 2) / chessSegment) * i);
    chessVertex.push(vec2(x_chess, y_chess));
  }
  return chessVertex;
}

接下来需要修改一些代码:

// 添加棋子
function addChess(vBuffer, x, y, width, height) {
  // 获取裁裁剪坐标
  const { x: x_gl, y: y_gl } = vertex2Gl(x, y, width, height);
+ const chessVertex = vertexChess(x_gl, y_gl);  
  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
  gl.bufferSubData(
    gl.ARRAY_BUFFER,
    (boardVertexNumber + chessVertexCount) * 8,
-   flatten(vec2(x_gl, y_gl)),
+   flatten(chessVertex),
  );
  // 每次加上棋子顶点数量
  chessVertexCount += chessSegment;
  render();
}

function render() {
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniform4fv(colorLoc, vec4(0.0, 0.0, 0.0, 1.0)); // 棋盘网格为黑色
  gl.drawArrays(gl.LINES, 0, boardVertexNumber); // 使用gl.LINES,每两个点绘制成一条不相连的线段
  for (let i = 0; i < chessVertexCount; i += chessSegment) {
-    gl.drawArrays(gl.POINTS, boardVertexNumber + i, chessSegment);
+    gl.drawArrays(gl.TRIANGLE_FAN, boardVertexNumber + i, chessSegment); // 改变绘制类型
  }
}

//着色器
<script id="vertex-shader" type="x-shader/x-vertex">
  attribute vec4 vPosition;
    uniform vec4 vColor;
    varying vec4 fColor;
    void
    main() {
-     gl_PointSize = 20.0;
      gl_Position = vPosition;
      fColor = vColor;
    }
</script>

这样每次点击屏幕,之前的点就变成圆点了

把WebGL的坐标转化为棋盘的格子坐标

目前我们棋子的坐标是鼠标的点击位置,这显然是不对的,棋子应该在网格两条线的交点。每个交点的坐标都是2/boardSegment(棋盘分段数)也就是0.1(2/20)的整数倍,可以把点击交点附近[-0.5, +0.5]的范围都转换为中间的交点。

由裁剪坐标得出棋子坐标的函数可以表示为:

/**
 * webGL裁剪坐标转化为棋子坐标
 * @param {Number} x  webGL x坐标
 * @param {Number} y  webGL y坐标
 */
function vertex2Board(x, y) {
  // 每个格子大小
  const diameter = 2 / boardSegment;
  return {
    x: Math.round(x / diameter) * diameter,
    y: Math.round(y / diameter) * diameter,
  };
}

然后修改一些代码:

// 添加棋子
function addChess(vBuffer, x, y, width, height) {
  // 获取裁裁剪坐标
  const { x: x_gl, y: y_gl } = vertex2Gl(x, y, width, height);
+ const { x: x_board, y: y_board } = vertex2Board(x_gl, y_gl);
-  const chessVertex = vertexChess(x_gl, y_gl);  
+  const chessVertex = vertexChess(x_board, y_board);  // 由棋子中心坐标求出棋子所有顶点坐标
  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
  gl.bufferSubData(
    gl.ARRAY_BUFFER,
    (boardVertexNumber + chessVertexCount) * 8,
    flatten(vec2(x_gl, y_gl)),
    flatten(chessVertex),
  );
  // 每次加上棋子顶点数量
  chessVertexCount += chessSegment;
  render();
}

这样每次点击棋盘,棋子的位置就始终位于交点了

保存棋盘棋子状态并判断输赢

初始化棋盘状态

想要知道输赢,需要把棋盘落子的状态用数据表示出来,我们可以用下面的数组表示下图中棋盘的状态:

[
[0, 0, 1, 2, 0, 0],
[0, 0, 1, 2, 0, 0],
[0, 0, 1, 2, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
]

图中是一个6x6的棋盘,0表示无子,1表示黑子,2表示白子。我们只需每次增加棋子时把相应位置的数据加进去,然后校验一下,新增的棋子附近有没有5个相同颜色的棋子即可。

首先增加一个棋盘状态变量,并封装一个初始化棋盘状态数据的函数

//...
let chessVertexCount = 0;
// 棋盘落子状态
+ const boardState = [];
/**
 * 初始化棋盘落子状态 0无棋子,1黑子,2白子
 * [
 *  [0,0,0...],
 *  [0,0,0...],
 *  ...
 * ]
 */
function initBoardState() {
  for (let i = 0; i < boardSegment + 1; i++) {
    boardState[i] = [];
    for (let j = 0; j < boardSegment + 1; j++) {
      boardState[i][j] = 0;
    }
  }
}

棋盘状态数据坐标函数

其次,还需要有一个棋盘坐标转化为棋盘状态数据中的坐标的函数。

举个🌰,在一个5x5棋盘中间棋子裁剪坐标是(0, 0),需要得出(2,2)表示第三行中的第三列

这和之前屏幕坐标转化为裁剪坐标很相似,只不过反过来了,现在是要把裁剪坐标转化为数组的行和列(和屏幕坐标一样最小值都在左上方)。看下图对比一下:

屏幕坐标转裁剪坐标是这样

function vertex2Gl(x, y, width, height) {
  return {
    x: (x / width) * 2 - 1,
    y: 1 - (y / height) * 2,
  };
}

只要反推棋盘状态坐标就出来了,但是这里widthheight就变成boardSegment也就是棋盘的行数和列数。

{
    x: ((x + 1) * width) / 2,
    y: ((1 - y) * height) / 2,
 }

因为浮点数计算精度的问题,结果会有偏差,数组的index肯定是整数,所以需要四舍五入

/**
 * 由棋子坐标得到棋盘状态中的坐标
 * @param {Number} x 棋子x坐标
 * @param {Number} y 棋子y坐标
 */
function vertex2State(x, y) {
  const width = boardSegment;
  const height = boardSegment;
  return {
    x: Math.round(((x + 1) * width) / 2),
    y: Math.round(((1 - y) * height) / 2),
  };
}

添加棋子修改棋盘状态

最后我们来修改一些代码。

添加一个棋子颜色的标识符,用来判断当前棋子颜色。

let const boardState = [];
+ // 棋子颜色标识
let colorFlag = true;
// 添加棋子
function addChess(vBuffer, x, y, width, height) {
  // 获取裁裁剪坐标
  const { x: x_gl, y: y_gl } = vertex2Gl(x, y, width, height);
  const { x: x_board, y: y_board } = vertex2Board(x_gl, y_gl);
+ const { x: x_state, y: y_state } = vertex2State(x_board, y_board);
  // 该位置有棋子return
+  if (boardState[y_state][x_state]) return;
  // 1为黑子,2为白子
+  boardState[y_state][x_state] = colorFlag ? 1 : 2;
  const chessVertex = vertexChess(x_board, y_board);  // 由棋子坐标求出棋子顶点坐标
  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
  gl.bufferSubData(
    gl.ARRAY_BUFFER,
    (boardVertexNumber + chessVertexCount) * 8,
    flatten(vec2(x_gl, y_gl)),
    flatten(chessVertex),
  );
  // 每次加上棋子顶点数量
  chessVertexCount += chessSegment;
  render();
+ colorFlag = !colorFlag; // 渲染完成之后修改棋子颜色
}

可以看到每次增加棋子,棋盘状态会相应发生变化:

棋盘状态变化后,判断输赢

终于到了最后一个步骤了,怎样才算赢呢?横向,纵向,两个斜角方向。四个方向只要有一个方向有五个同样的棋子就算赢了。

拿横向判断举个🌰:棋子A落下后,先向左方查找,指到遇到和A颜色不一样的棋子或者到达左边边界停止。然后向A右方查找同样是直到遇到和A颜色不一样的棋子或者到达右边边界停止,记录期间一共有几个相同的。其他方向同理,都是先从一个方向找,再向反方向找。下面的例子是一共有4个。

下面试着写下判断横向的函数

/**
 * 
 * @param {Number} x 落下棋子在棋盘状态中的x坐标
 * @param {Number} y 落下棋子在棋盘状态中的x坐标
 * @param {Number} chessType 棋子类别 1或2
 */
function check(x, y, chessType){
  // 相同个数的棋子
  let connected = 1;
  // 左方是index减少方向
  let decreaseDirection = 1;
  // 右侧是index增加方向
  let increaseDirection = 1;
  // 除去落下的棋子,只需要寻找四次就够了
  for (let i = 0; i < 4; i++) {
    // 首先向左侧寻找
    // 没有达到左边边界 && 左侧棋子状态等于落下棋子的颜色
    if (x - decreaseDirection >= 0 && boardState[y][x - decreaseDirection] === chessType) {
        // 相同个数的棋子增加
        connected ++ ;
         // 继续向左侧的下一个位置查找
        decreaseDirection ++ ;
        // 没有达到右边边界 && 右侧棋子状态等于落下棋子的颜色
    } else if (x + increaseDirection <= boardSegment && boardState[y][x + increaseDirection] === chessType) {
         // 相同个数的棋子增加
        connected ++ ;
         // 继续向右侧侧的下一个位置查找
        increaseDirection ++ ;
    }
  }
   return connected >= 5;
}

下面试着用下,我们修改一些代码:

// 添加棋子
function addChess(vBuffer, x, y, width, height) {
  // 获取裁裁剪坐标
  const { x: x_gl, y: y_gl } = vertex2Gl(x, y, width, height);
  const { x: x_board, y: y_board } = vertex2Board(x_gl, y_gl);
  const { x: x_state, y: y_state } = vertex2State(x_board, y_board);
  // 该位置有棋子return
  if (boardState[y_state][x_state]) return;
  // 1为黑子,2为白子
  boardState[y_state][x_state] = colorFlag ? 1 : 2;
  const chessVertex = vertexChess(x_board, y_board);  // 由棋子坐标求出棋子顶点坐标
  gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
  gl.bufferSubData(
    gl.ARRAY_BUFFER,
    (boardVertexNumber + chessVertexCount) * 8,
    flatten(vec2(x_gl, y_gl)),
    flatten(chessVertex),
  );
  // 每次加上棋子顶点数量
  chessVertexCount += chessSegment;
  render();
  // 检查是否获胜
+ checkFinished(x_state, y_state, colorFlag);
  colorFlag = !colorFlag; // 渲染完成之后修改棋子颜色
}
/**
 * 
 * @param {Number} x 落下棋子在棋盘状态中的x坐标
 * @param {Number} y 落下棋子在棋盘状态中的x坐标
 * @param {Boolean} colorFlag 目前棋子颜色标识 true黑色 false白色
 */
function checkFinished(x, y, colorFlag) {
  const chessType = colorFlag ? 1 : 2;
  if(check(x, y, chessType)){
      console.log('win')
  }
}

看起来不错 接下来还有纵向和两个斜角方向,纵向和横向基本一致就不讲了。这里讲下右上-左下的情况。两个方向x,y都有增加,减少各有一个。判断条件就稍微复杂些

// 右上-左下
// (x未到右边界 && y未到上边界) && 右上方向棋子颜色是落下棋子的颜色
if ((x + decreaseDirection <= boardSegment && y - decreaseDirection >= 0) && 
        boardState[y - decreaseDirection][x + decreaseDirection] === chessType) {
          connected += 1;
          decreaseDirection++;
        // (x未到左边界 && y未到下边界) && 左下方向棋子颜色是落下棋子的颜色
        } else if ((x - increaseDirection >= 0 && y + increaseDirection <= boardSegment) &&
        boardState[y + increaseDirection][x - increaseDirection] === chessType) {
          connected += 1;
          increaseDirection++;
    }

下面列出所有类型的检查条件函数,四个方向的判断条件基本一致都是下一坐标在棋盘范围内&&棋子颜色相同

/**
 * 
 * @param {Number} x 落下棋子在棋盘状态中的x坐标
 * @param {Number} y 落下棋子在棋盘状态中的x坐标
 * @param {Number} chessType 棋子类别 1或2
 * @param {String} checkType 检查类型 row,col,diagonal_left(左上-右下),diagonal_right(右上-左下)
 */
function check(x, y, chessType, checkType) {
  let connected = 1;
  // index减少方向的个数 分别是 左,上,左上,右上
  let decreaseDirection = 1;
  // index增加方向的个数 分别是 右,下,右下,左下
  let increaseDirection = 1;
  for (let i = 0; i < 4; i++) {
    // 每个检查类型增加方向和减少方向的 坐标和落子状态检验
    switch (checkType) {
      case 'row':
        if (x - decreaseDirection >= 0 && boardState[y][x - decreaseDirection] === chessType) {
          connected += 1;
          decreaseDirection++;
        } else if (x + increaseDirection <= boardSegment && boardState[y][x + increaseDirection] === chessType) {
          connected += 1;
          increaseDirection++;
        }
        break;
      case 'col':
        if (y - decreaseDirection >= 0 && boardState[y - decreaseDirection][x] === chessType) {
          connected += 1;
          decreaseDirection++;
        } else if (y + increaseDirection <= boardSegment && boardState[y + increaseDirection][x] === chessType) {
          connected += 1;
          increaseDirection++;
        }
        break;
      case 'diagonal_left':
        if ((x - decreaseDirection >= 0 && y - decreaseDirection >= 0)
        && boardState[y - decreaseDirection][x - decreaseDirection] === chessType) {
          connected += 1;
          decreaseDirection++;
        } else if ((x + increaseDirection <= boardSegment && y + increaseDirection <= boardSegment) &&
        boardState[y + increaseDirection][x + increaseDirection] === chessType) {
          connected += 1;
          increaseDirection++;
        }
        break;
      case 'diagonal_right':
        if ((x + decreaseDirection <= boardSegment && y - decreaseDirection >= 0) && 
        boardState[y - decreaseDirection][x + decreaseDirection] === chessType) {
          connected += 1;
          decreaseDirection++;
        } else if ((x - increaseDirection >= 0 && y + increaseDirection <= boardSegment) &&
        boardState[y + increaseDirection][x - increaseDirection] === chessType) {
          connected += 1;
          increaseDirection++;
        }
        break;
      default:
        break;
    }
  }
  return connected >= 5;
}

再来修改下checkFinished()函数,使用一个标签显示当前获胜方:

/**
 * 
 * @param {Number} x 落下棋子在棋盘状态中的x坐标
 * @param {Number} y 落下棋子在棋盘状态中的x坐标
 * @param {Boolean} colorFlag 目前棋子颜色标识 true黑色 false白色
 */
function checkFinished(x, y, colorFlag) {
  const chessType = colorFlag ? 1 : 2;
  if (check(x, y, chessType, "row")) {
    winHandle(colorFlag);
    return;
  } else if (check(x, y, chessType, "col")) {
    winHandle(colorFlag);
    return;
  } else if (check(x, y, chessType, "diagonal_left")) {
    winHandle(colorFlag);
    return;
  } else if (check(x, y, chessType, "diagonal_right")) {
    winHandle(colorFlag);
    return;
  }
}

function winHandle(colorFlag) {
  winner = colorFlag ? "黑子" : "白子";
  winnerEl.innerText = winner + "胜"
}
// 获胜方
+ let winner;
+ const winnerEl = document.getElementById('winner')

//...
// 有获胜方则不允许落子
canvas.addEventListener("click", evt => {
+   if (winner) return;
    const { clientX: x, clientY: y } = evt;
    const { width, height } = canvas;
    addChess(vBuffer, x, y, width, height);
});

来看下效果:

体验优化

至此功能基本做完,但是还是有个体验需要优化,就是每次落子没有标记,容易忘记上次在哪落的子。所以我们需要在每次落子加一个标记。这就用到了我们之前vertex2State()由棋子坐标得到棋盘状态中的坐标函数,这个函数也是棋子坐标转换成屏幕坐标函数,他们的原理都一样。为了避免混淆,再声明一个新的函数:

/**
 * 由棋子坐标得到屏幕坐标
 * @param {Number} x 棋子x坐标
 * @param {Number} y 棋子y坐标
 */
function vertex2Screen(x, y, width, height) {
  return {
    x: (x + 1) / 2 * width,
    y: (1 - y) / 2 * height
  };
}

最后在每次render之后重置标记位置

// 最后棋子标记
+ const lastEl = document.getElementById('last');

// 添加棋子
function addChess(vBuffer, x, y, width, height) {
  // ...
  render();
  // 获取棋子屏幕坐标,标记最后一个棋子的位置
+  const { x: x_screen, y: y_screen } = vertex2Screen(x_board, y_board,width, height);
+   lastEl.style.display = `block`;  //默认隐藏
+   lastEl.style.left = `${x_screen}px`;
+   lastEl.style.top = `${y_screen}px`;
  // 检查是否获胜
  checkFinished(x_state, y_state, colorFlag);
  colorFlag = !colorFlag;
}

大功告成👏👏👏👏👏

参考