做一盘大棋

1,938 阅读7分钟

引言,没有,免得虐待动物。(大雾)
先上实战项目地址,求star
Chinese Checkers by React.js & Socket.io.

跳棋

跳棋的游戏规则很简单,棋子的移动可以一步步在有直线连接的相邻六个方向进行,如果相邻位置上有任何方的一个棋子,该位置直线方向下一个位置是空的,则可以直接“跳”到该空位上,“跳”的过程中,只要相同条件满足就可以连续进行。谁最先把正对面的阵地全部占领,谁就取得胜利。一玩就懂,所以几乎每个人都下过跳棋。在香港跳棋被称为“波子棋”。

大概就是张这样子。

跳棋长相

哈哈,不要被迷倒,你下棋的样子最认真。

好,我们接下来开始实现她。

基本实现思路

  1. 先创建一个canvas元素。
  2. 先得出棋盘所有的坐标,用空心圆渲染。
  3. 再找到所有玩家的坐标,然后用不同颜色的实心圆渲染玩家的棋子。
  4. 监听canvas的点击事件,判断出是否在操作棋子,并做出相应的响应:
    如果是移动棋子,先在目标位置画上实心圆,再清理圆形区域,再画上空心圆。

是不是非常的easy,下面我们一步一步的来。

我们先温习一下 canvas 中的绘图

比如,先画一个空心圆,用来表示没有被压上棋子的空棋盘格子,好奇怪,为什么是压上。。。

function strokeArc(x, x, borderColor, radius) {
  ctx.beginPath();
  ctx.arc(x, x, radius, 0, 2 * Math.PI, true);
  ctx.strokeStyle = borderColor;
  ctx.stroke();
}

再来画个实心圆,当然这个是用来表示棋子的

function fillArc(x, y, color, radius) {
  ctx.beginPath();
  ctx.arc(x, x, radius, 0, 2 * Math.PI, true);
  ctx.fillStyle = color;
  ctx.fill();
}

好像都很容易吧,哈哈。当棋子移动的时候,就需要把之前一坨实心圆给清理掉,再画上空心圆,基本思路, canvas 里面有clearRect方法,文档翻烂都没有找到clearArc,咋办,还好有 globalCompositeOperation 这个属性,来我们自己 DIY 一个出来。

function cleanArc(x, y, radius) {
  ctx.globalCompositeOperation = 'destination-out';
  this.fillArc(x, y, radius * 1.1);
  ctx.globalCompositeOperation = 'source-over';
}

接着定义一下棋盘坐标

显然,不能直接使用canvas的坐标体系。棋盘是个六边形,所以,那就歪啦,这个歪坐标系,我称之为跳棋坐标系。横着的是X轴(自左向右自增),竖着的是Y轴(由上向下自增)。

棋盘坐标

缓存一个变量pos,用来缓存所有的坐标,用来关联跳棋坐标系和canvas中真实位置。其数据结构如下:

  // 棋盘的坐标
  let pos = {
    '1-5': {
      ID: '1-5',
      x: 1,  // 跳棋坐标系x
      y: 5,  // 跳棋坐标系y
      _x: 0,  // 实际上的垂直坐标系的x
      _y: 60,  // 实际上的垂直坐标系的y
      playerID: 'A'  // 可能不存在该属性。若存在则代表当前坐标上有一枚该角色的棋子
    },
    // ...
  };

  // 全局统一的跳棋坐标系中的ID样式
  function getID(x, y) {
    if (Object.prototype.toString.call(x) === '[object Object]') return `${x.x}-${x.y}`;
    return `${x}-${y}`;
  }

这好几百个位置,一个一个算太麻烦了,我们仔细看一下,发现还是有点规律。比如:
x坐标的为1,y的坐标只能是5,
x坐标的为2,y的坐标的上限是5,下限是6
...
这只需要找到所有的可能的x坐标就搞定了啊。哈哈,写段脚本跑起来。

  // 初始化棋盘,一个巨大的六角形东西
  function drawBoard(canvasWidth, radius) {
    const spaceWidth = canvasWidth / 14;
    const lineHeight = canvasWidth / 15;
    const sapceX = spaceWidth / 2;
    const padding = 2 * radius;

    // 坐标系区域的限制
    // x坐标为1,y的上限是5,下限是5
    // x坐标为2,y的上限是5,下限是6
    const posRegions = [[5, 5], [5, 6], [5, 7], [5, 8],  [1, 13], [2, 13], [3, 13], [4, 13], [5, 13], [5, 14], [5, 15], [5, 16], [5, 17], [10, 13], [11, 13], [12, 13], [13, 13]];

    posRegions.forEach((regions, i) => {
      let x = i + 1;
      let min = regions[0];
      let max = regions[1];
      for (let y = min; y <= max; y++) {
        // 需要修正一下 x 轴的实际坐标
        let correct = 0;
        if (y < 5) correct = (5 - y) * sapceX;
        if (y > 5) correct = -(y - 5) * sapceX;
        let _x = i * spaceWidth + correct + padding;
        let _y = y * lineHeight;

        // 画棋盘
        strokeArc(_x, _y);
        // 记录位置
        let ID = this.getID(x, y);
        pos[ID] = { x, y, _x, _y, ID };
      }
    });
  }

棋盘都搞定了,棋子还远么。

渲染玩家棋子

准备一个players变量来缓存所有跟玩家棋子相关的东西,包括玩家棋子所在区域的开始位置,玩家颜色等什么的。

  // 初始化的棋盘的6个区域内的坐标限制
  let players = {
    A: {
      color: 'rgba(255, 165, 0, 1)',
      area: { x: 5, y: 1 }  // 起始坐标x,y
    },
    B: {
      color: 'rgba(0, 255, 0, 0.25)',
      area: { x: 10, y: 5, special: 1 }  // 特殊的角
    },
    C: {
      color: '#f44336',
      area: { x: 14, y: 10 }
    },
    D: {
      color: '#5badf0',
      area: { x: 10, y: 14, special: 1 }
    },
    E: {
      color: '#e91e63',
      area: { x: 5, y: 10 }
    },
    F: {
      color: '#ff9800',
      area: { x: 1, y: 5, special: 1 }
    }
  };

  // 根据角色初始化玩家的棋子
  // playerID --> 'ABCDEF'.substr(0, 1);
  function initPlayer(playerID) {
    if (!players.hasOwnProperty(playerID)) return;
    let palyer = players[playerID];

    for (let i = 0; i < 4; i++) {
      let j = palyer.area.special ? 0 : i;
      let maxY = palyer.area.special ? i + 1 : 4;
      for (; j < maxY; j++) {
        let x = palyer.area.x + i;
        let y = palyer.area.y + j;
        let ID = getID(x, y);
        let newPos = pos[ID];
        if (!newPos) continue;

        pos[ID].playerID = playerID;
        fillArc(newPos._x, newPos._y, players[playerID].color);
      }
    }
  }

如果您有耐心看到这里,几乎就成功了一大半了。

canvas中的点击事件

众所周知 canvas 不同于 svg 没有 DOM ,添加事件只能添加给整个 canvas ,这个时候就非常尴尬了,那么多棋子,咋弄!
别慌,我们有办法。
基本思路就是我们可以通过 event 获得鼠标或者手指的点击坐标,然后根据坐标相对于整个 canvas 左上角的偏移得出当前点击的相对位置,然后再遍历一下,看当前这个坐标是否是棋盘中的有效坐标,这样我们就得到了点击的这个位置是不是 棋子/空格/空白地区 的方法。

// 通过event事件获得点击的坐标
function getPointByEvent(ev) {
  if (ev.layerX || ev.layerX === 0) return { x: ev.layerX, y: ev.layerY };
  // Opera
  if (ev.offsetX || ev.offsetX === 0) return { x: ev.offsetX, y: ev.offsetY };
}

// 通过某坐标找出其是否属于一个棋子/棋盘区域内
function getPieceByPoint(point) {
  for (let i in pos) {
    let piece = pos[i];
    // 勾股定理,求出这个点击的坐标点距离圆心的长度
    let a = ~~(point.x - piece._x);
    let b = ~~(point.y - piece._y);
    let len = Math.sqrt(a * a + b * b);
    if (len < radius) return piece;
  }
}

点击事件都搞定了,这后面的就太容易了。

棋子的移动

还记得前面说的什么吗?一个棋子从一个位置移动到另一个位置,只需要在新位置画上带颜色的实心圆,然后把原位置清空,画上空心的棋盘就大功告成。
需要注意的,移动棋子要怎么操作。

当然是先拎起来,在选个位子,松手,吧唧一下,棋子就移动过去了。
用js事件解释就是,点击一个棋子,选中;鼠标挪动,点击一个棋盘的空格,落子。

这么梳理后就简单了。
来来来,整起走。

  // 缓存选中的棋子
  let currentPiece;
  // 描绘棋子移动
  function renderMove(newPos) {
    let oldPos = currentPiece;
    newPos.playerID = oldPos.playerID;  // 传递下去,好赋颜色
    fillArc(newPos._x, newPos._y, players[oldPos.playerID].color);
    cleanArc(oldPos._x, oldPos._y);
    strokeArc(oldPos._x, oldPos._y);
    delete this.pos[oldPos.ID].playerID;
  }

是不是格外的简单,清晰。。。

结语

可能有的朋友会问,如果我乱走棋子咋办,都没检测一下落子合法性什么的,别慌,来再给你们放一次我的项目地址: Chinese Checkers by React.js & Socket.io.

在这个项目中实现了,自定义用户名、可选人数、系统判定玩家走步次序、检查玩家落子的合法性、玩家的走位落子历史记录、多端同步通讯等等......基本达到了可玩的状态。
当然,这只是个开始,如果有时间和精力会继续写下去的,我想要整个漂亮的界面、下棋的时候聊天、倒计时、人机对战等等好多。

感谢您的阅读,希望能参与我的小游戏,或者给我的这个跳棋一点点意见,或者去给我点个赞鼓舞一下我吧。

谢谢!

群友建议,改个名字,哈哈