JS写一个2048

2017-12-23
最近没找到喜欢的项目视频,准备自己写点什么看看到底学得如何,偶然间发现了别人写的贪吃蛇和2048,觉得写个这玩意儿也可以当做小练手.于是在扔了10次硬币之后选择了自己做个2048出来.这里是成品地址

一.游戏规则与核心代码

做游戏嘛,首先要搞明白规则,百度上说2048是一位叫Gabriele Cirulli的大佬写的,便去github上顺藤摸瓜找到了项目主页:2048.玩了一会觉得这种游戏可能不适合自己,最高玩到1024就死掉了...
2048的游戏规则就是可以通过方向键/按钮/念力来让所有方块向同一方向移动,如果遇到相同的方块就把两个方块上的数字相加生成一个新方块,每次移动/相加都会在随机空位置生成一个值为2的方块.
了解游戏规则之后就方便多了,无非就是两种操作: 移动(Move)和结合(Merge).可以把2048看成4*4的矩阵,没有数字的位置就是0,那么移动方块就是将它与边上的0互换位置,以左移为例的话,MoveLeft()应该是从方块A[i]j开始向左寻找非0位置B[i]k,找到后如果B[i]k和A[i]j相等,则B[i]k*2,A[i]j=0,否则将方块A移动到B[i]k的右边[i]k+1处,同时A[i]j=0.
MoveLeft 落实到代码上就是这样:

var moveLeft = function () {  //左移
  for (var i = 0; i < len; i++) {
    for (var j = 1; j < len; j++) {  //从[0][1]开始遍历数组,因为是左移,最左面一行不用检查
      if (board[i][j] != 0) {  //遇见非零位[i][j]时
        var k = 1;
        while (j - k >= 0 && board[i][j - k] === 0) {  //从[i][j]向左检查k位
          k = k + 1;  //若[i][j-k]为0则继续向左检查
        }
        if (j - k >= 0) {  //[i][j-k]依然在数组中
          if (board[i][j - k] === board[i][j]) {  //相加
            board[i][j - k] = board[i][j - k] * 2;
            board[i][j] = 0;
          } else if (k > 1) {  //若检查了不止一位,防止后面[j-k+1]===[j]时实际上没有移动
            var tmp = board[i][j];  //将[i][j]移到[i][j-k+1]
            board[i][j] = 0;
            board[i][j - k + 1] = tmp;
          }
        } else {  //若检查了j位全为0,将[i][j]移到最左面[i][0]
          board[i][0] = board[i][j];
          board[i][j] = 0;
        }
      }
    }
  }
};

同理可以写出右移,上移,下移的函数,区别在于遍历数组的顺序以及检查的方向.

该方法目前有BUG,会导致0224进行左移的时候直接变为8000,不会一步一步的结合.

核心代码的第二部分就是在随机位置生成方块2,这里采用的方法有两种:
1.利用Math.random()方法生成两个随机数(i,j)作为随机位置的坐标[i]j,检查该位置是否为0,为0则[i]j=2,否则调用自身重新生成随机数i,j.这种方法就是逻辑上比较简单,但是明显能看出还是有很多缺点,比如若数组已满,要判断至少16次才能确定,以及判断数组已满的方法又是两次循环.所以这里采用第二种方法生成随机坐标.

var randNum = function () {
  var ranRow = Math.floor(Math.random() * 4);
  var ranCol = Math.floor(Math.random() * 4);
  if (board[ranRow][ranCol] != 0) {
    if (isFull()) {
      return;
    } else {
      randNum();
    }
  } else {
    board[ranRow][ranCol] = 2;
  }
  return;
}

2.遍历数组,生成一个包含所有0坐标的新数组list,比较list的长度与所要生成随机数坐标的个数n,若list.length小于坐标个数则将list中每个坐标所对应的原数组的值设为2,否则在新数组中随机抽取n个元素作为生成随机数的坐标.这种方法的好处在于计算次数少,检查数组已满的方式简单,并且可以自定义新随机数的个数.

var generateNewNumbers = function (increasement) {
  var num = increasement || 1;
  var list = new Array();
  for (var i = 0; i < 4; i++) {
    for (var j = 0; j < 4; j++) {
      if (board[i][j] === 0) {
        list.push({
          posX: i,
          posY: j
        });
      }
    }
  };
  if (list.length < num) {
    for (var i = 0; i < list.length; i++) {
      var x = list[i].posX;
      var y = list[i].posY;
      board[x][y] = 2;
    }
  } else {
    for (var i = 0; i < num; i++) {
      var element = Math.floor(Math.random() * list.length);
      var x = list[element].posX;
      var y = list[element].posY;
      board[x][y] = 2;
      list.splice(element,1);
    }
  }
};

这样,两个核心的函数就完成了.

二.失败与胜利的判定

游戏嘛,当然要有失败和胜利.因此我们有两个函数isWon()isDead().胜利好说,遍历数组如果有2048的话就判定胜利,失败的话则要判定数组中已经没有位置生成新方块,且每个元素的上下左右都没有相同项可以合并.这里我们给出isDead()的实现:

var isDead = function () {
  for (var i = 0; i < len; i++) {
    for (var j = 0; j < len; j++) {
      if (board[i][j] === 0) {
        return false;
      }
    }
  }
  for (var i = 0; i < len; i++) {
    for (var j = 0; j < len; j++) {
      if (i - 1 >= 0 && board[i][j] === board[i - 1][j]) {
        return false;
      } else if (i + 1 < len && board[i][j] === board[i + 1][j]) {
        return false;
      } else if (j - 1 >= 0 && board[i][j] === board[i][j - 1]) {
        return false;
      } else if (j + 1 < len && board[i][j] === board[i][j + 1]) {
        return false;
      }
    }
  }
  return true;
};

这样,代码逻辑方面就完成了.接下来开始进行动画制作.

三.HTML元素操作与动画

在开始写2048之前,我以为代码逻辑是最难的...然而在写完代码逻辑之后我发现原来做页面动画才是最痛苦的.动画的关键在于,如何在动画时间与HTML元素变化之间找到平衡,比如结合(Merge())会在同一位置产生三个元素,该在什么时候清理冗余元素成了个大问题.
最早的时候我准备用setTimeout()来进行延迟操作,但是多个setTimeout()会在全部代码执行完毕后再一起执行,造成了逻辑上的混乱,便换了其他方法.
方块移动和结合的动画我放到了同一个函数animateMove()里,通过向函数传入五个参数:起始i坐标,起始j坐标,偏移量k,是否结合merge,以及移动方向ifVertical来确定移动动画的具体形式.方块移动可以视为把A的坐标改成B的坐标,利用CSS的transition-duration来实现移动动画,结合则通过.append()方法在HTML元素后添加一个方块,利用@keyframes来实现新方块的出现动画.

var animateMove = function (fromX, fromY, k, merge, ifVertical) { //k终点坐标
  var para = ifVertical || 0 //是否是垂直移动
  if (para) { //垂直移动
    $(".tile-position-" + fromX + "-" + fromY).removeClass("tile-position-" + fromX + "-" + fromY).addClass("tile-position-" + k + "-" + fromY);
    if (merge) {
      setTimeout(function () {
        animateMerge(k, fromY);
      }, 200);
    }
  } else { //水平移动
    $(".tile-position-" + fromX + "-" + fromY).removeClass("tile-position-" + fromX + "-" + fromY).addClass("tile-position-" + fromX + "-" + k);
    if (merge) {
      setTimeout(function () {
        animateMerge(fromX, k);
      }, 200);
    }
  }
};

var animateMerge = function (val1, val2) {
  score = score + 5;
  $("#score").text(score);
  if (board[val1][val2] != 0) {
    $(".tile-container").append("<div class='tile tile-merged tile-position-" + val1 + "-" + val2 + "'><div class='inner-val val-" + board[val1][val2] + "'>" + board[val1][val2] + "</div></div>");
    $(".tile-position-" + val1 + "-" + val2).not(".tile-merged").remove();
  }
};

当然我们不能一味地在结合的过程中添加元素,那样会造成HTML元素的冗余,有无用元素堆积到页面上.而如何清理这些元素也考验技巧.最初我采用的是在某一时刻重新绘制全部方块,不过重新绘制的时机很难掌握,在快速操作方块移动的时候经常会来不及显示完整的动画.因此我在执行合并动画的1s后将含有"tile-merged"类的元素的"tile-merged"类删除.唔,暂时还没发现什么BUG.

四.监控键盘操作

当然可以选择点击按钮操作游戏,不过显然使用方向键更为方便.jQuery的.keydown()方法很好地实现了这个功能,在按键按下时传入按键的ASCII码,通过不同的传入值来执行不同的操作..preventDefault()则可以防止按方向键时页面发生滚动.

  var keyMonitor = function () {
    $(document).keydown(function (event) {
      var e = event || window.event;
      var k = e.keyCode || e.which;
      switch (k) {
        case 38: //Up
        e.preventDefault();
        moveUp();
        isTriggered();
        break;
      case 40: //Down
        e.preventDefault();
        moveDown();
        isTriggered();
        break;
      case 37: //Left
        e.preventDefault();
        moveLeft();
        isTriggered();
        break;
      case 39: //Right
        e.preventDefault();
        moveRight();
        isTriggered();
        break;
    }
  });
};