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

前言

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

会议结束后同事表示:

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

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

思路(步骤)

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

实现

第一步:提供一个canvas标签

html 复制代码
<!-- html只需提供一个canvas标签 -->
<canvas id="canvas"></canvas>

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

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

js 复制代码
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是有区别的」,即

html 复制代码
<canvas id="canvas" width="300" height="300"></canvas>

html 复制代码
<canvas style="width: 300px; height: 300px"></canvas>

或者

css 复制代码
#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,那么画布实际大小应该是多少呢?「元素宽高和画布宽高之间应该有一个比例」。

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

js 复制代码
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方法

js 复制代码
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个交叉点。仔细想想,画个草图如下:

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

js 复制代码
// 线条数量默认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中调用一下,并且绘制线条

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

定义绘制线条的方法

js 复制代码
// 画线
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();
  });
}

此时完成效果如下:

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

js 复制代码
// 计算所有的交叉点
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中调用一下

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

第五步:监听点击事件

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

js 复制代码
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 };
}

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

js 复制代码
// 计算离点击的位置最近的一个交叉点坐标
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);
}

改一下处理点击事件

js 复制代码
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();
}

到这里看下落子效果

第六步:判断输赢

实现上一步中的watchWin方法

js 复制代码
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颗棋子

只要达成一种情况就赢了

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

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

思路分析:

  1. 横向在一条线,说明y轴的值是一样的
  2. 多条横线都会有同一个玩家的棋子
  3. 所以按y轴分组
js 复制代码
// 判断横向
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;
    }
  }
};

判断竖向

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

js 复制代码
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轴大小排序

js 复制代码
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);
};

看下效果

在线体验

github

相关推荐
蛋蛋_dandan2 天前
Fabric.js从0到1实现图片框选功能
canvas
wayhome在哪4 天前
用 fabric.js 搞定电子签名拖拽合成图片
javascript·产品·canvas
德育处主任4 天前
p5.js 掌握圆锥体 cone
前端·数据可视化·canvas
德育处主任5 天前
p5.js 3D 形状 "预制工厂"——buildGeometry ()
前端·javascript·canvas
德育处主任7 天前
p5.js 3D盒子的基础用法
前端·数据可视化·canvas
掘金安东尼7 天前
2分钟创建一个“不依赖任何外部库”的粒子动画背景
前端·面试·canvas
百万蹄蹄向前冲7 天前
让AI写2D格斗游戏,坏了我成测试了
前端·canvas·trae
用户25191624271110 天前
Canvas之画图板
前端·javascript·canvas
FogLetter13 天前
玩转Canvas:从静态图像到动态动画的奇妙之旅
前端·canvas
用户25191624271114 天前
Canvas之贪吃蛇
前端·javascript·canvas