JS写一个2048

AsukaShikinamiLangley
AsukaShikinamiLangley / 2048
0
0
一个我从来没有玩通关过的游戏

最近没找到喜欢的项目视频, 准备自己写点什么看看到底学得如何, 偶然间发现了别人写的贪吃蛇和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](值为0)处, 同时A[i][j]=0.
MoveLeft 落实到代码上就是这样:

JavaScript
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次才能确定, 以及判断数组已满的方法又是两次循环. 所以这里采用第二种方法生成随机坐标.
JavaScript
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;
}
  1. 遍历数组,生成一个包含所有0坐标的新数组list, 比较list的长度与所要生成随机数坐标的个数n, 若list.length小于坐标个数则将list中每个坐标所对应的原数组的值设为2, 否则在新数组中随机抽取n个元素作为生成随机数的坐标. 这种方法的好处在于计算次数少, 检查数组已满的方式简单, 并且可以自定义新随机数的个数.
JavaScript
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()的实现:

JavaScript
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来实现新方块的出现动画.

JavaScript
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()则可以防止按方向键时页面发生滚动.

JavaScript
  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;
    }
  });
};