一百五十行代码实现一个canvas五子棋游戏

992 阅读7分钟

作者:小影前端团队——阿星

本文通过使用简单的canvas技术实现一个破产版的五子棋游戏,为初学canvas的小伙伴提供对一些基本概念的了解,以及记录我的学习心得,欢迎大家多多交流,也请各位大佬不吝赐教。效果图如下

1122.png

游戏规则

游戏和一般的五子棋规则一样,但很多地方做了简化。

  1. 五子连通用即代表游戏胜利
  2. 先手通过点击黑子或白子决定
  3. 悔棋按钮可以取回棋子

设计思路

  1. 绘制15 * 15 的网格作为棋盘
  2. 游戏分为两种模式
    1. 玩家点击棋子按钮后才能在棋盘下棋子
    2. 玩家下棋子后可以选中棋子并移动
  3. 悔棋时将保存棋子数组的最后一项弹出

关键点

绘制棋盘

在canvas绘制的过程中如果想绘制出我们想要的效果,需要不断设置绘制上下文的属性。

如代码所示:在绘制网格前我设置了要绘制网格的线宽、线的颜色以及设置了填充颜色用于清空画布。

function drawGrid(color, stepX, stepY) {

   // 保存之前的上下文
   context.save()
  
   // 设置线宽、线的颜色
   context.strokeStyle = color;
   context.lineWidth = 0.5;

   // 清空画布
   context.fillStyle = '#ffffff';
   context.fillRect(0, 0, context.canvas.width, context.canvas.height);
   
   // 开启一段子路径
   context.beginPath();

   // 绘制列 
   for (var i = stepX + 0.5; i < context.canvas.width; i += stepX) {
     context.moveTo(i, 0);
     context.lineTo(i, context.canvas.height);
   }

   // 绘制行
   for (var i = stepY + 0.5; i < context.canvas.height; i += stepY) {
     context.moveTo(0, i);
     context.lineTo(context.canvas.width, i);
   }

   //对当前路径进行描边
   context.stroke();

   // 恢复之前的上下文
   context.restore();

}
  1. context.save()和context.restore() 绘制上下文属性管理

上面的两个方法在canvas绘制中是很常用的,他们的作用很简单,save 将该方法调用之前的上下文push入栈中,restore 方法将栈顶的上下文弹出。因为canvas在绘制的过程中需要不断的设置绘制上下文的属性,在绘制对象之前将当前的属性保存,然后设置当前对象的属性,绘制完成后恢复之前的属性。这样能保证每个对象都被应用正确的绘制属性。

  1. context.beginPath() context.stroke()

在canvas中大多数的图形绘制都是通过路径实现的, beginPath 方法会清除子路径列表,开启一个新的子路径。这里的子路径可以想象为透明直线或曲线组成的区域(可能是未闭合的)调用stroke 方法会对当前存在的子路径进行描边。调用fill 方法会对当前存在的子路径进行填充。

为什么beginPath 在开启一段路径时要清除子路径列表,因为stroke、fill 方法应用于当存在的所有子路径。这会导致前面绘制的路径样式被覆盖。具体如下:

ctx.beginPath();
ctx.moveTo(100.5,20.5);
ctx.lineTo(200.5,20.5);
ctx.strokeStyle = 'black'; // 设置描边为黑色
ctx.stroke();


ctx.moveTo(100.5,40.5);
ctx.lineTo(200.5,40.5);
ctx.strokeStyle = 'red'; // 设置描边为红色
ctx.stroke(); // 由于未使用beginPath,上面的黑色线段会被新描边为红色

效果图如下:

1133.png

  1. canvas像素边界问题

注意上面在绘制行和列时,都会加上i = stepX + 0.5 ,这是由于像素边界导致的,具体原因看下图:

1144.png

如图所示:canvas的绘制表面可以看作由1px宽高的行和列组成,当我们绘制一条起点为整数像素,线宽为1px的线时,线的中心会默认放在绘制的起点。如上moveTo(3, 1) lineTo(3, 5) 。线中心会处于3px处,因为像素无法在每一列或行显示为0.5px,所以会线性插值填满整格。导致1px看起来更粗切模糊。解决方法就是默认加上0.5像素,将线中心移动到格中心。

  1. 绘图表面(绘制表面的宽高和canvas的大小是两个完全不同的东西)

在绘制棋盘前通过以下代码设置了背景色。

context.fillStyle = '#ffffff';

// 这里设置的宽高是绘图表面的宽高

context.fillRect(0, 0, context.canvas.width, context.canvas.height);

这里有一个绘图表面的概念。我将其理解会我们通过canvas api绘制时可见坐标系的大小(理论上这个坐标系是无穷大的,不过超出绘图表面大小的物体不显示)绘图表面的大小可以通过两种方式设置

  1. canvas.width = 720; canvas.height = 750;

绘图表面是我们在进行绘制时可感知的,而可见的canvas大小受另外一种因素的影响 —— css的样式(width、height、boder、padding、box-sizing...)。其中boder、padding、box-sizing影响的是宽高属性的计算。

当设置绘制表面的宽高和设置canvas的大小不一致时,会对绘图表面进行缩放(也影响在canvas上面的坐标获取)。如下图:

1155.png

可以看出图二绘制的图形被放大了两倍。所以发现绘制的效果在大小上不正常可以看看是不是这个原因。代码如下:

<canvas width="600" height="300" style="border: 1px solid #000;"></canvas>
<canvas width="300" height="150" style="height: 600px; height: 300px; border: 1px solid #000;"></canvas>
<script>
  const [canvas1, canvas2] = document.querySelectorAll('canvas');
  const ctx1 = canvas1.getContext('2d');
  const ctx2 = canvas2.getContext('2d');

  draw(ctx1, canvas1);
  draw(ctx2, canvas2);

  function draw(ctx, canvas) {

    ctx.font = '38px Arial';
    ctx.fillStyle = 'cornflowerblue';
    ctx.strokeStyle = 'blue';
    ctx.fillText("Hello Canvas", canvas.width / 2 - 150, canvas.height / 2 + 15);
    ctx.strokeText("Hello Canvas", canvas.width / 2 - 150, canvas.height / 2 + 15 ); 
  }
  </script>

获取canvas上面的点击坐标

这里在获取坐标时考虑了绘图表面和canvas css大小不一的问题。

(x - box.left) * (canvas.width / box.width) 通过将计算后的坐标乘以缩放比,得到在绘制表面上要应用的正确坐标。注意这里的坐标位置还受canvas padding、border 的影响。所以尽量不要给canvas设置这些样式属性。

// x e.clientX 点击X坐标
// y e.clientY 点击Y坐标
function windowToCanvas(x, y) {
   // 获取canvas元素的坐标信息
   var box = canvas.getBoundingClientRect();
   return { 
      x: (x - box.left)  / (box.width  / canvas.width),
      y: (y - box.top)  / (box.height / canvas.height)
   };
}

背景的保存与恢复

canvas绘制模式属于即时模式(还有一种是保留模式),所以当发生变化时需要将所有的绘制对象重新绘制(不包括clip这种技术)。

这里引用一个例子

假设我们现在需要一个数字2 ,然后我又需要两个数字 20,再者我们需要数字201,最后我们需要数字2019。

我们会怎么做呢?先拿出一张纸,写下一个数字2,然后在2后面写下 0,而后在0后面写下1,最后在1 后面写下9;这样我们就得到了2019 。这种模式就是保留模式。

或者我们先拿出一张纸写下2 ,然后再拿出一张纸写下20 ,而后拿出一张纸写下201 ,最后再拿出一张纸写下2019。这种模式就是立即模式。

保留模式会在内存中保存状态,当有需要改变的时候,执行的是改变的处理,也就是说前后不改变的地方不会变化。但是立即模式不是这样,立即模式会每次都重新绘制所有的元素,不管这个对象改变的是一点点还是两点点,也就是说不会像保留模式那样,占用过高的内存资源。

为了简单,每次变化我都会重新绘制所有的图形对象。有想法的大佬们可以考虑将绘制一段变化前的canvas保存为ImageData,在每次绘制变化前作为背景填充。然后绘制变化对象。

棋子绘制之径向渐变

为了模拟棋子,我采取了一种很简单的方案,通过径向渐变产生色差绘制棋子,代码如下:

// 获取一个渐变对象
const radialGradient = context.createRadialGradient(
  this.x,
  this.y,
  this.radius - 2, // 外部圆心和半径
  this.x,
  this.y,
  0 // 内部圆心和半径
);

// 判断棋子的类型,设置不同的渐变色
if (this.type === "black") {
  radialGradient.addColorStop(0, "#0A0A0A");
  radialGradient.addColorStop(1, "#636766");
} else {
  radialGradient.addColorStop(0, "#D1D1D1");
  radialGradient.addColorStop(1, "#F9F9F9");
}

context.fillStyle = radialGradient;

context.fill();

对象拾取

在五子棋游戏中,我们需要拖动棋子。这就要求我们知道当前选中的是那个棋子,对其的坐标进行计算,然后重新绘制。

canvas拾取方案 这里有一个比较详细的canvas拾取方案介绍。我采取的是第二种,通过canvas内置的apicontext.isPointInPath(loc.x, loc.y)进行对象拾取。这个api很简单,判断当前鼠标的位置是否在当前子路径中。所以我们所有创建的棋子,都需要将其路径存信息储起来。在判断拾取对象时遍历子路径,当返回为true时,表示当前对象正被拾取。代码如下:

// Chessman class

class Chessman {
  constructor(centerX, centerY, radius, type) {
    this.x = centerX;
    this.y = centerY;
    this.radius = radius;
    this.type = type;
  }
  
  createPath(context) {
    context.beginPath();
    context.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false);
    context.closePath();
  }

}

// chessmanList 是棋子对象列表
chessmanList.forEach( function (chessman) {

  chessman.createPath(context);

  if (context.isPointInPath(loc.x, loc.y)) {
     target = chessman;
     draggingOffsetX = loc.x - chessman.x;
     draggingOffsetY = loc.y - chessman.y;

     return;
  }
});

具体代码

Html

<!DOCTYPE html>

<html>
  <head>
    <title>五子棋游戏</title>
    <style>
      body { position: relative; background: #eeeeee; height: 100vh; }
      #canvas {
         position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: auto;
         cursor: pointer; background: #ffffff;box-shadow: -1px -1px 1px #6F6767, 1px 1px 1px #6F6767;
      }
      .btnWrap { position: relative; top: 40px; text-align: center; }
    </style>
  </head>
   <body>
      <div class="btnWrap">
         <button>黑子</button>
         <span style="display: inline-block;width: 240px;"></span>
         <button>悔棋</button>
         <span style="display: inline-block;width: 240px;"></span>
         <button>白子</button>
      </div>
    <script src = './chessman.js'></script>
    <script src = 'example.js'></script>
  </body>
</html>

棋子类

class Chessman {
  constructor(centerX, centerY, radius, type) {
    this.x = centerX;
    this.y = centerY;
    this.radius = radius;
    this.type = type;
  }

  createPath(context) {
    context.beginPath();
    context.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false);
    context.closePath();
  }

  draw(context) {

    context.save();
    this.createPath(context);
    const radialGradient = context.createRadialGradient(
      this.x,
      this.y,
      this.radius - 2,
      this.x,
      this.y,
      0
    );
    if (this.type === "black") {
      radialGradient.addColorStop(0, "#0A0A0A");
      radialGradient.addColorStop(1, "#636766");
    } else {
      radialGradient.addColorStop(0, "#D1D1D1");
      radialGradient.addColorStop(1, "#F9F9F9");
    }
    context.fillStyle = radialGradient;
    context.fill();
    context.restore();
  }

  move(x, y) {
    this.x = x;
    this.y = y;
  }
}

绘制代码

let canvas  = document.getElementById('canvas'),
    context = canvas.getContext('2d'),
    
    blackBtn = document.querySelectorAll('button')[0],
    backBtn  = document.querySelectorAll('button')[1],
    whiteBtn = document.querySelectorAll('button')[2],

    type,// 当前要绘制的棋子类型

    mousedown = {}, // 点击坐标

    target, // 选中的棋子

    draggingOffsetX, // x方向移动偏移
    draggingOffsetY, // y方向移动偏移
    dragging = false, // 是否在移动棋子,点击下棋按钮后不可移动,绘制完棋子后可移动
    
    chessmanList = []; // 保存棋子对象的列表

function drawGrid(color, stepX, stepY) {

   context.save()

   // 设置线宽、线的颜色
   context.strokeStyle = color;
   context.lineWidth = 1;

   // 清空画布
   context.fillStyle = '#ffffff';
   context.fillRect(0, 0, context.canvas.width, context.canvas.height);
   context.beginPath();

   for (let i = stepX + 0.5; i < context.canvas.width; i += stepX) {
     context.moveTo(i, 0);
     context.lineTo(i, context.canvas.height);
   }
  
   for (let i = stepY + 0.5; i < context.canvas.height; i += stepY) {
     context.moveTo(0, i);
     context.lineTo(context.canvas.width, i);
   }
   context.stroke();
   context.restore();
}

// x e.clientX 点击X坐标
// y e.clientY 点击Y坐标
function windowToCanvas(x, y) {
   // 获取canvas元素的坐标信息
   const box = canvas.getBoundingClientRect();
   return { 
      x: (x - box.left)  / (box.width  / canvas.width),
      y: (y - box.top)  / (box.height / canvas.height)
   };
}

// 添加棋子
function drawChessman() {
   if (!type) return;
   const chessman = new Chessman(mousedown.x, mousedown.y, 16, type,);
   chessman.draw(context);
   chessmanList.push(chessman);
   dragging = true;
}

// 绘制所有棋子
function drawPolygons() {
   chessmanList.forEach( function (chessman) {
      chessman.draw(context);
   });

}

// 保存点击坐标
function savePosition(loc) {
  mousedown.x = loc.x;
  mousedown.y = loc.y;
}

backBtn.onclick = function (e) {
   chessmanList.pop();
   drawGrid('#c7c1c1', 50, 50);
   drawPolygons();

};

blackBtn.onclick = function (e) {
   type = 'black';
   dragging = false;
};

whiteBtn.onclick = function (e) {
   type = 'white';
   dragging = false;
};

canvas.onmousedown = function (e) {
   const loc = windowToCanvas(e.clientX, e.clientY);
   e.preventDefault();
   savePosition(loc);

   // 如果可以移动,则找到要移动的对象
   if (dragging) {
      chessmanList.forEach( function (chessman) {
        chessman.createPath(context);
        if (context.isPointInPath(loc.x, loc.y)) {
           target = chessman;
           draggingOffsetX = loc.x - chessman.x;
           draggingOffsetY = loc.y - chessman.y;
           return;
        }

     });

   } else {
     drawChessman();
   }

};

canvas.onmousemove = function (e) {
   const loc = windowToCanvas(e.clientX, e.clientY);
   e.preventDefault();
   if (dragging && target) {

      target.move(loc.x - draggingOffsetX, loc.y - draggingOffsetY);
      drawGrid('#c7c1c1', 50, 50);
      drawPolygons();
   }
};

canvas.onmouseup = function (e) {
   target = null;
};

// init

drawGrid('#c7c1c1', 50, 50);

招贤纳士

小影前端团队,是一个年轻、活动且富有创造力的团队,隶属于小影科技开发中心,Base 在风景如画的杭州。团队在日常的业务对接之外,还在互动技术、图像渲染、跨端技术、工程化平台、性能体验、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,并一直保持好奇,持续探索前端技术。 如果你想大胆自信的表达你的想法;如果你想有个机会实现自己的想法;如果你希望落地的想法有一个团队来支撑;如果你愿意跟一群积极向上的小伙伴干一些持续迭代自己,持续提升技术能力的事;如果你相信相信的力量;那就加入我们吧!快戳链接》》quvideo.jobs.feishu.cn/s/e94EjWC