公司内部技术分享,我写了一个五子棋

前言

经常和朋友玩「五子棋」微信小游戏双人对战,我就在想为什么不自己开发一个呢?正赶上公司这周的内部技术分享会排到我了,我就写了一个五子棋。由于时间有限,先完成单机模式!

会议结束后同事表示:

原来这么简单,我也去写一个!

在线体验地址 和 github源码 见文末

思路(步骤)

  1. 技术选型:页面使用canvas渲染
  2. 确定画布和棋盘大小
  3. 绘制棋盘网格
  4. 找到所有的棋子落点(网格线条交叉点坐标)
  5. 监听点击事件,判断落子位置,在交叉点上绘制棋子
  6. 判断输赢(五子连珠)

实现

第一步:提供一个canvas标签


<canvas id="canvas">canvas>

第二步:确定画布和棋盘大小

设置canvas元素默认宽高和基本样式

class FiveLine {
  /** 
   * 接收canvas的dom
   * css宽高可选,默认500px
   * 为什么叫cssWidth、cssHeight,
   * 是因为这个值表示的只是dom元素在网页内显示的宽高
   * 绘制出来的图形大小往往会比这个大,显示出来会更清晰
   */
  constructor(canvas, {cssWidth = 500, cssHeight = 500} = {}) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    this.cssWidth = cssWidth;
    this.cssHeight = cssHeight;
    // 定义当前玩家,默认为B,代表黑色棋子
    this.curPlayer = 'B';
    // 定义保存玩家已经放下的所有棋子的坐标数组
    this.pieces = [];
    // 初始化画布的样式
    this.init()
  }
  init() {
    const { ctx, canvas } = this;
    // 设置canvas元素的宽高
    canvas.style.width = this.cssWidth + "px";
    canvas.style.height = this.cssHeight + "px";
    // 棋盘背景
    ctx.fillStyle = "#dbb263";
    ctx.fillRect(0, 0, width, height);
    // 格子线条样式
    ctx.lineWidth = 1;
    ctx.strokeStyle = "#000";
  }
}

确定绘制出来的图像的实际大小

为什么会有这一步,顺便补充一下我们容易忽略的基础问题,请查看解决canvas在高清屏中绘制模糊的问题

回过头来我们就知道了,这里涉及到一个概念,「canvas的width、height属性与canvas元素style的width、height是有区别的」,即

<canvas id="canvas" width="300" height="300">canvas>

<canvas style="width: 300px; height: 300px">canvas>

或者

#canvas {
  width: 300px;
  height: 300px;
}

是有区别的。

  • width 和 height 属性表示canvas元素的实际宽高,决定了canvas画布的大小。
  • style 的 width 和 height 表示canvas元素在页面布局中的尺寸,是标准的CSS样式,决定了canvas标签本身的大小。
  • 两者的区别在于:
  • width/height改变的是画布大小,影响绘制的图形大小和清晰度
  • style width/height改变的是元素大小,影响页面布局,但不改变画布本身
  • 也就是说:width/height 控制绘图空间,style width/height 控制元素空间

综上所述,我们已经知道了canvas元素的宽高是500px,那么画布实际大小应该是多少呢?「元素宽高和画布宽高之间应该有一个比例」。

增加一个获取像素比的方法:

getPixelRatio(context) {
  const backingStore =
   context.backingStorePixelRatio ||
   context.webkitBackingStorePixelRatio ||
   context.mozBackingStorePixelRatio ||
   context.msBackingStorePixelRatio ||
   context.oBackingStorePixelRatio ||
   1;
  // window.devicePixelRatio:当前显示设备的物理像素分辨率与CSS 像素分辨率之比
  return Math.round((window.devicePixelRatio || 1) / backingStore);
 }

知道了这个比例,我们来改一下init方法

init() {
    const { ctx, canvas } = this;
    // 设置canvas元素的宽高
    canvas.style.width = this.cssWidth + "px";
    canvas.style.height = this.cssHeight + "px";
    // 设置canvas的实际宽高
    canvas.width = this.cssWidth * this.pixelRatio(ctx);
    canvas.height = this.cssHeight * this.pixelRatio(ctx);
    // 棋盘背景
    ctx.fillStyle = "#dbb263";
    ctx.fillRect(0, 0, width, height);
    // 格子线条样式
    ctx.lineWidth = 1;
    ctx.strokeStyle = "#000";
}

第三步:绘制棋盘网格

我们都知道在canvas世界中,「线条是由点组成的」,两个点连起来就有了一条线,所以绘制线条要先知道「起点和终点的坐标」,现在一般五子棋的棋盘都是由「横竖15条线」组成,一共产生255个交叉点。仔细想想,画个草图如下:

公司内部技术分享,我写了一个五子棋_第1张图片

获取横竖每个线条的起点和终点坐标

// 线条数量默认15
getLinePoints(lineNum = 15) {
    const { width, height } = this.canvas
    // 一般五子棋游戏棋盘都会有一个外边距
    // 同方向15条线一共组成14个格子
    // 每个格子的宽度为:
    const gap = width / lineNum;
    // 外边距设置为格子宽度的一半
    // 可以得到左上角第一个点的位置
    const start = gap / 2;
    
    // 生成线条起点和终点坐标
    const row = [];
    const col = [];
    for (let i = 0; i < lineNum; i++) {
        row.push({
            startX: start,
            startY: start + i * gap,
            endX: width - gap / 2,
            endY: start + i * gap
        });
    }
    for (let i = 0; i < lineNum; i++) {
        col.push({
            startX: start + i * gap,
            startY: start,
            endX: start + i * gap,
            endY: height - gap / 2
        });
    }
    return { row, col, gap };
}

在init中调用一下,并且绘制线条

init() {
  // ...前面的就省略了
  // 得到15条横线、15条竖线的开始和结束坐标,以及格子的宽度,即两两线条的间距
  const { row, col, gap } = this.getLinePoints();
  // gap保存到全局,后面用得上
  this.gap = gap;
  
  // 绘制线条
  this.drawLine(ctx, row, col);
}

定义绘制线条的方法

// 画线
drawLine(ctx, row, col) {
  // 循环画15条横线
  row.forEach((item, index) => {
    // 每画完一条都重新开始
    ctx.beginPath();
    ctx.moveTo(item.startX, item.startY);
    ctx.lineTo(item.endX, item.endY);
    ctx.stroke();
    ctx.closePath();
  });
  // 循环画15条竖线
  col.forEach((item, index) => {
    ctx.beginPath();
    ctx.moveTo(item.startX, item.startY);
    ctx.lineTo(item.endX, item.endY);
    ctx.stroke();
    ctx.closePath();
  });
}

此时完成效果如下:

公司内部技术分享,我写了一个五子棋_第2张图片

第四步:找到所有的线条交叉点

// 计算所有的交叉点
getCrossPoints(row, col) {
  const points = [];
  row.forEach((r) => {
      col.forEach((c) => {
          const A = [r.startX, r.startY];
          const B = [r.endX, r.endY];
          const C = [c.startX, c.endY];
          const D = [c.endX, c.startY];
          const intersection = this.getIntersection(A, B, C, D);
          if (intersection) {
              points.push(intersection);
          }
      });
  });
  return points;
}
// 获取AB和CD两条线的交点坐标
// A:开始坐标
// B:结束坐标
// C:开始坐标
// D:结束坐标
getIntersection(A, B, C, D) {
  const x1 = A[0],
      y1 = A[1],
      x2 = B[0],
      y2 = B[1],
      x3 = C[0],
      y3 = C[1],
      x4 = D[0],
      y4 = D[1];
  const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
  if (denominator === 0) return null; // The lines are parallel
  const x =
      ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) /
      denominator;
  const y =
      ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) /
      denominator;
  return [x, y];
}

在init中调用一下

init() {
  // ...前面的就省略了
  // 获取所有交叉点坐标,并保存到全局
  this.crossPoints = this.getCrossPoints(row, col);
}

第五步:监听点击事件

添加绑定事件的方法,在init调用一下

init() {
  // ...前面的就省略了
  this.bindEvent()
}

bindEvent() {
  const { canvas } = this;
  // 监听点击画布
  canvas.addEventListener("click", this.handleClick)
}
// 处理点击事件
handleClick(e) {
  const { canvas } = this;
  // 获取点击的坐标
  const { x, y } = this.getMousePos(canvas, e)
}
// 获取鼠标点击在canvas内以左上角为基点的坐标
getMousePos(canvas, event) {
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;
  // 前面我们设置的canvas大小跟css宽高不一致,所以要乘以比例
  return { x: x * this.pixelRatio, y: y * this.pixelRatio };
}

现在有一个问题,我们已经拿到了点击的坐标,但是「点击的位置不一定刚好在交叉点上」(这是一个极小概率事件),而我们的目标是以交叉点为圆心画圆(棋子),那么就要「找到离点击位置最近的一个交叉点」坐标,在那个位置上落子。

// 计算离点击的位置最近的一个交叉点坐标
nearestPoint(point, coords) {
  let minDist = Infinity;
  let nearest;
  for (let coord of coords) {
    let dist = this.getDistance(point, coord);
    if (dist < minDist) {
      minDist = dist;
      nearest = coord;
    }
  }
  return nearest;
}
getDistance(p1, p2) {
  let dx = p1[0] - p2[0];
  let dy = p1[1] - p2[1];
  return Math.sqrt(dx * dx + dy * dy);
}

改一下处理点击事件

handleClick(e) {
  const { canvas } = this;
  // 获取点击的坐标
  const { x, y } = this.getMousePos(canvas, e)
  // 把实际点击的坐标和所有交叉点坐标传进去
  const point = this.nearestPoint([x, y], this.crossPoints);
  console.log('实际落子位置 ===>', point)
  // 判断点击的位置是否已经有棋子了
  if (
    this.pieces.find((c) => c.point[0] === point[0] && c.point[1] === point[1])
  ) {
    alert("此处已有棋子");
    return;
  }
  // 保存此次棋子,标记是属于哪个玩家的
  this.pieces.push({
    player: this.curPlayer,
    point
  });
  // 绘制棋子
  this.drawPiece(
    point[0],
    point[1],
    this.curPlayer === "B" ? "black" : "white"
  );
  // 监听是否已有五子相连
  const isWin = this.watchWin();
  if (!isWin) {
    // 还没有出现胜者
    // 变更下一次的玩家
    this.curPlayer = this.curPlayer === "W" ? "B" : "W";
  } else {
    // 有人胜利了
    setTimeout(() => {
      alert(this.curPlayer === "B" ? "小黑赢了" : "小白赢了")
    }, 0)
  }
}
// 绘制棋子
drawPiece(x, y, color) {
  const { ctx } = this;
  // 阴影
  ctx.shadowColor = "#ccc";
  ctx.shadowBlur = this.gap / 3;
  ctx.beginPath();
  ctx.arc(x, y, this.gap / 3, 0, Math.PI * 2);
  ctx.closePath();
  ctx.fillStyle = color;
  ctx.fill();
}

到这里看下落子效果

公司内部技术分享,我写了一个五子棋_第3张图片

第六步:判断输赢

实现上一步中的watchWin方法

watchWin() {
  // 我们前面是把两个玩家的棋子都放到了一个数组里
  // 并且标记了每个棋子是谁的
  // 现在要分开
  // 当前玩家是谁,就判断谁的棋子是否达成五连
  const { pieces, curPlayer } = this;
  const B = pieces
    .filter((item) => item.player === "B")
    .map((item) => item.point);
  const W = pieces
    .filter((item) => item.player === "W")
    .map((item) => item.point);
  
  // 检查玩家的棋子是否达成五连并返回boolean值
  return this.checkWin(curPlayer === "B" ? B : W)
}

判断输赢有三种情况

  1. 横着连起来5颗棋子
  2. 竖着连起来5颗棋子
  3. 斜方向连起来5颗棋子

只要达成一种情况就赢了

// 检查是否赢了
checkWin(coordinates) {
  // 玩家棋子数量小于5直接返回
  if (coordinates.length < 5) return;
  
  return (
    this.transverse(coordinates) || 
    this.vertical(coordinates) || 
    this.slant(coordinates)
  )
}

判断横向是否有连续5颗棋子

思路分析:

  1. 横向在一条线,说明y轴的值是一样的
  2. 多条横线都会有同一个玩家的棋子
  3. 所以按y轴分组
// 判断横向
transverse(arr) {
  // 把数据保存到一个对象
  let obj = {};
  
  // 先按x轴坐标大小升序,便于比较
  const xCoordinates = JSON.parse(JSON.stringify(arr)).sort(
    (a, b) => a[0] - b[0]
  );
  // 按y轴分组
  xCoordinates.forEach((item) => {
    if (obj[item[1]]) {
      obj[item[1]].push(item);
    } else {
      obj[item[1]] = [item];
    }
  });
  console.log("[ obj ] >", obj);
  for (const y in obj) {
    // 统计一条线上连续棋子的数量
    let count = 1;
    const element = obj[y];
    if (element.length >= 5) {
      // 一条横线上的连续棋子数量大于等于5才有效
      for (let i = 1; i < element.length; i++) {
        if (element[i][0] === element[i - 1][0] + this.gap) {
          // 因为前面排序了的
          // 如果下一个坐标的x轴等于上一个坐标的x轴 + 线条间距
          // 说明是连续的,数量+1
          count++;
        }
      }
      // 一旦大于等于5,就赢了
      return count >= 5;
    }
  }
};

判断竖向

竖向和横向的思路是一样的

vertical(arr) {
  let obj = {};
  
  // 按y轴大小排序
  const yCoordinates = JSON.parse(JSON.stringify(arr)).sort(
    (a, b) => a[1] - b[1]
  );
  
  // 按x轴分组
  yCoordinates.forEach((item) => {
    if (obj[item[0]]) {
      obj[item[0]].push(item);
    } else {
      obj[item[0]] = [item];
    }
  });
  for (const x in obj) {
    let count = 1;
    const element = obj[x];
    if (element.length >= 5) {
      for (let i = 1; i < element.length; i++) {
        if (element[i][1] === element[i - 1][1] + this.gap) {
            count++;
        }
      }
      return count >= 5;
    }
  }
};

判断斜向

斜向分两种情况,从左下角到右上角和从左上角到右下角,思路也是先按x轴大小排序

slant(arr) {
  // 按x轴大小升序
  const xCoordinates = JSON.parse(JSON.stringify(arr)).sort(
    (a, b) => a[0] - b[0]
  );
  const findFiveInARow = (points) => {
    // 将点转换为字符串,并放入一个集合中,以便我们可以快速查找它们
    const pointSet = new Set(points.map((p) => p.join(",")));

    // 遍历每一个点
    for (let p of points) {
      // 检查右斜线方向
      let rightDiagonal = [];
      for (let i = 0; i < 5; i++) {
        const nextPoint = [p[0] + i * this.gap, p[1] + i * this.gap];
        if (pointSet.has(nextPoint.join(","))) {
          rightDiagonal.push(nextPoint);
        } else {
          break;
        }
      }

      // 如果找到了五个连续的点,返回它们
      if (rightDiagonal.length === 5) {
        return rightDiagonal;
      }

      // 检查左斜线方向
      let leftDiagonal = [];
      for (let i = 0; i < 5; i++) {
        const nextPoint = [p[0] + i * this.gap, p[1] - i * this.gap];
        if (pointSet.has(nextPoint.join(","))) {
          leftDiagonal.push(nextPoint);
        } else {
          break;
        }
      }

      // 如果找到了五个连续的点,返回它们
      if (leftDiagonal.length === 5) {
        return leftDiagonal;
      }
    }
    return false;
  };

  return findFiveInARow(xCoordinates);
};

看下效果

公司内部技术分享,我写了一个五子棋_第4张图片

在线体验

github

你可能感兴趣的:(canvas)