前端项目2048小游戏

第一章 2048游戏介绍

1.1 项目介绍

相信大家都玩过2048这个游戏,这次我们就一步步地来完成2048的开发,这个项目它需要用到html css和js的知识。

需求分析:
1 游戏是一个4x4的方格,每一个方格我们称为Tile或者Cell
2 游戏开始会随机出现2个方格,每个的值90%的可能性为2,10%的可能性为4
3 通过键盘的上下左右键可以控制方格按一个方向移动,直到不能移动为止。
4 如果移动以后两个Tile 的内容值一样,则进行合并。
5 每个 Tile 移动会有 100ms 的移动动画。
6 每个 Tile 的出现有个短暂的放大效果
7 每次 Tile 的合并有个短暂的放大回弹效果
8 顶部 Score 记录当前分数,BestScore 记录有史以来最高分,每次合并都会产生分数的变化,分数计算规则为:分数 = 原来分数 + 合并后的值。
9 游戏将时时刻刻记录进度,刷新页面重现游戏进度。
10 当某个 Tile 的值为 2048,游戏胜利。
11 当每个方格都有值,并且相邻两个方格无法再进行合并,则游戏结束

实战技术知识点
1 静态页面渲染:需要 HTML、CSS 基础知识,包括学习的SCSS知识。
2 开始游戏等事件处理:需要使用 DOM 监听事件。
3 Tile 移动处理:需要监听键盘事件(暂时不处理 H5 中手势事件的情况)。
4 Tile 动态随机添加:需要使用 DOM 动态操作。
5 Tile 移动,合并: 需要使用 Javascript 列表,对象,方法等数据结构和常用技巧。
6 Tile 动画:需要使用 CSS 的 transform 和 animation 等动画效果。
7 本地缓存:需要使用 Javascript localStorage 浏览器缓存。
8…

2048游戏链接

第二章 2048静态页面开发

2.1 静态页面开发

我们先看看页面结构

整个css的文件比较大,为了更加清晰的理解 CSS 文件。我们利用 SCSS @import 特性对文件进行分离,如下文件目录。

|-- images
|-- style
    |-- index.scss  // scss入口文件 + footer
    |-- nav.scss    // 头部区域文件
    |-- main.scss   // 主体区域文件
    |-- desc.scss   // 描述区域文件
|-- index.html

我们利用scss 变量声明中心方格区域的长宽,间隔等属性,如果以后我们需要适配移动端,只需要修改这里的变量值即可,
这就是scss的优势—可编程的 CSS。

利用html和css完成下面静态页面的开发

接下来我们渲染加入了方块的静态页面:如下图

我们来分析一下方块的相同点和不同点:
1 它们都有一样的大小,圆角,动效。所以我们需要设置一个统一的 class 为tile。
2 它们每个数字颜色和字体大小都不同,因此我们需要为每个值设置单独的样式,class 为tile-(x)(x 为 2、4、8、16 …… 2048)。
3 它们的位置可以总结为行(row),列(column),因此我们可以使用绝对定位进行布局,class 为title-position-(row)-(column)。
4 每个元素都有移动(translate)和 放缩(scale)动画,因为两个动画都是transform的一个属性,会出现冲突。因此我们将每个 Tile
分为外框tile和tile-inner两个部分,tile用于元素移动,title-inner用于元素放缩。
我们完成静态页面的渲染:

第三章 2048 对象设计

###3.1 2048 对象设计

tile对象,我们编写js的时候考虑文件分离,考虑tile对象时,把每个方格当成一个tile对象。那么每个Tile应该有
row,column,value三个属性分别表示行、列、值,对应的 JS 代码为:

// tile.js
function Tile(position, value) {
  this.row = position.row;
  this.column = position.column;
  this.value = value;
}

grid对象,它是管理tile对象的,它是4x4的方格。

// grid.js
function Grid(size = 4) {
  this.size = size;
}

用grid对象来存储Tile内容,在这里我们可以使用二维数组来存储:

//grid.js
function Grid(size = 4) {
  this.size = size;
  this.cells = [];
  this.init();
}

// prototype 设置方法
Grid.prototype.init = function(size) {
  for (let row = 0; row < size; row++) {
    this.cells.push([]);
    for (let column = 0; column < size; column++) {
      this.cells[row].push(null);
    }
  }
};

引入js

<html>
  ...
  <body>
    ...
    <script src="./scripts/tile.js"></script>
    <script src="./scripts/grid.js"></script>
    <script src="./scripts/index.js"></script>
  </body>
</html>

往grid添加tile

//grid.js

//...

Grid.prototype.add = function(tile) {
  this.cells[tile.row][tile.column] = tile;
};

uml图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XhTi1Shm-1604221108564)(https://style.youkeda.com/img/course/f10/3/7.svg)]

3.2 2048 对象渲染

我们增加一个render对象来完成渲染

//render.js
function Render() {}

// 渲染整个grid
Render.prototype.render = function(grid) {
  for (let row = 0; row < grid.size; row++) {
    for (let column = 0; column < grid.size; column++) {
      // 如果grid中某个cell不为空,则渲染这个cell
      if (grid.cells[row][column]) {
        this.renderTile(grid.cells[row][column]);
      }
    }
  }
};

// 渲染单个tile
Render.prototype.renderTile = function(tile) {};

然后我们渲染每一个tile:

//render.js
function Render() {
  this.tileContainer = document.querySelector('.tile-container');
}

// 渲染单个tile
Render.prototype.renderTile = function(tile) {
  // 创建一个tile-inner
  const tileInner = document.createElement('div');
  tileInner.setAttribute('class', 'tile-inner');
  tileInner.innerHTML = tile.value;

  // 创建一个tile
  const tileDom = document.createElement('div');
  let classList = [
    'tile',
    `tile-${tile.value}`,
    `tile-position-${tile.row + 1}-${tile.column + 1}`
  ];
  tileDom.setAttribute('class', classList.join(' '));
  tileDom.appendChild(tileInner);
  this.tileContainer.appendChild(tileDom);
};

3.3 2048随机初始化

我们来看看需求:游戏开始能随机出现 2 个 Tile,每个的值 90%可能为 2,10%可能为 4
所以我们需要Grid所有的空闲方格,然后利用随机数,随机获取其中一个方格,创建Tile对象,并且设置 Value 值
所有可用的方格:

// grid.js
// 获取所有可用方格的位置
Grid.prototype.availableCells = function() {
  const availableCells = [];
  for (let row = 0; row < this.cells.length; row++) {
    for (let column = 0; column < this.cells[row].length; column++) {
      // 如果当前方格没有内容,则其可用(空闲)
      if (!this.cells[row][column]) {
        availableCells.push({ row, column });
      }
    }
  }
  return availableCells;
};

随机某个可用的方格:

// grid.js
// 随机获取某个可用方格的位置
Grid.prototype.randomAvailableCell = function() {
  // 获取到所有的空闲方格
  const cells = this.availableCells();
  if (cells.length > 0) {
    // 利用Math.random()随机获取其中的某一个
    return cells[Math.floor(Math.random() * cells.length)];
  }
};

index.js利用随机空闲位置创建节点

let grid = new Grid();
let render = new Render();
for (let i = 0; i < 2; i++) {
  // 90%概率为2,10%为4
  const value = Math.random() < 0.9 ? 2 : 4;
  // 随机一个方格的位置
  const position = grid.randomAvailableCell();
  // 添加到grid中
  grid.add(new Tile(position, value));
}
render.render(grid);

第四章 2048移动处理

4.1 键盘监听

重构manager :我们从index抽离一个manager类来承担游戏控制器的作用。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xbMeoD60-1604221108565)(https://style.youkeda.com/img/course/f10/4/1.svg)]

//manager.js
function Manager(size = 4) {
  this.size = size;
  this.grid = new Grid(size);
  this.render = new Render();
  this.start();
}

Manager.prototype.start = function() {
  for (let i = 0; i < 2; i++) {
    // 90%概率为2,10%为4
    const value = Math.random() < 0.9 ? 2 : 4;
    // 随机一个方格的位置
    const position = this.grid.randomAvailableCell();
    // 添加到grid中
    this.grid.add(new Tile(position, value));
  }
  this.render.render(grid);
};

键盘监听:

window.addEventListener('keyup', function(e) {
  console.log(e.keyCode);
});

###4.2监听回调控制

我们需要添加一个监听器:

function Listener() {
  window.addEventListener('keyup', function(e) {
    switch (e.keyCode) {
      case 38:
        console.log('向上');
        break;
      case 37:
        console.log('向左');
        break;
      case 39:
        console.log('向下');
        break;
      case 40:
        console.log('向右');
        break;
    }
  });
}

事件回调:
为了Listener响应键盘事件以后,能回传到Manager进行操作控制,我们需要给Listener传递一个回调函数。

//listener.js
function Listener({ move: moveFn }) {
  window.addEventListener('keyup', function(e) {
    switch (e.keyCode) {
      case 38:
        moveFn('向左');
        break;
      case 37:
        moveFn('向上');
        break;
      case 39:
        moveFn('向下');
        break;
      case 40:
        moveFn('向右');
        break;
    }
  });
}

这就是一个非常典型的回调函数的用法,我们可以通过传递moveFn到Listener,当Listener触发键盘事件以后,回调Manager

4.3 方向向量化

向量化:方向在计算机之中一般用向量来表示。
我们来看看朝不同方向移动时,向量的变化:

=> {row: 0, column: -1}              //行不变,列减一
右 => {row: 0, column: 1}=> {row: -1, column: 0}=> {row: 1, column: 0}

// 原始位置 + 向量 = 现在位置
// {row: 1, column: 1} + {row: 0, column: -1} = {row: 1, column: 0} //朝左移动

4.4移动位置计算


在不考虑方块合并的情况下,我们来看看移动规则:
规则 1:同一排或同一列的方块移动顺序跟随具体的方向,比如上图中:向上移动
2 先移动,4 后移动;向下移动 4 先移动,2 后移动
规则 2:每个方格都是移动到该方向的最后一个空白位置。

代码实现,遍历顺序:
我们设置一个方法,根据方向返回移动的路径。

//manager.js

Manager.prototype.getPaths(direction){
 let rowPath = [];          
 let columnPath = [];         
 return {rowPath, columnPath} //返回行遍历顺序和列遍历顺序
}

小知识:key和value 如果相同,可以简写

{
 rowPath, columnPath;
}

// 等同于

{
 rowPath: rowPath,
 columnPath: columnPath
}

我们加入正常的从左上到右下的顺序:

Manager.prototype.getPaths = function(direction) {
 let rowPath = [];
 let columnPath = [];
 for (let i = 0; i < this.size; i++) {
   rowPath.push(i);
   columnPath.push(i);
 }
 return {
   rowPath,
   columnPath
 };
};

优化上面的代码:
Manager.prototype.getPaths = function(direction) {
 let rowPath = [];
 let columnPath = [];
 for (let i = 0; i < this.size; i++) {
   rowPath.push(i);
   columnPath.push(i);
 }

 // 向右的时候
 if (direction.column === 1) {
   columnPath = columnPath.reverse();
 }

 // 向下的时候
 if (direction.row === 1) {
   rowPath = rowPath.reverse();   //此方法可用使数组里面的顺序发生颠倒
 }
 return {
   rowPath,
   columnPath
 };
};

寻找方块移动的目标地址:

// 寻找移动方向目标位置
Manager.prototype.getNearestAvaibleAim = function(aim, direction) {
 // 位置 + 方向向量的计算公式
 function addVector(position, direction) {
   return {
     row: position.row + direction.row,
     column: position.column + direction.column
   };
 }
 aim = addVector(aim, direction);

 // 获取grid中某个位置的元素
 let next = this.grid.get(aim);

 // 如果next元素存在(也就是此目标位置已经有Tile),或者是超出游戏边界,则跳出循环。目的:就是找到最后一个空白且不超过边界的方格
 while (!this.grid.outOfRange(aim) && !next) {
   aim = addVector(aim, direction);
   next = this.grid.get(aim);
 }

 // 这时候的aim总是多计算了一步,因此我们还原一下
 aim = {
   row: aim.row - direction.row,
   column: aim.column - direction.column
 };

 return {
   aim,
   next
 };
};

4.5tile移动处理

方块移动的思路:
根据方向获取遍历顺序,跟随顺序进行遍历
遍历时候,如果此位置上有 Tile,则进行移动
根据当前 Tile 的位置和方向,获取目标移动位置
进行 Tile 移动
只要有一个节点产生移动,则重新调用渲染器渲染 grid

// manager.js
Manager.prototype.listenerFn = function(direction) {
 // 定义一个变量,判断是否引起移动
 let moved = false;

 const { rowPath, columnPath } = this.getPaths(direction);
 for (let i = 0; i < rowPath.length; i++) {
   for (let j = 0; j < columnPath.length; j++) {
     const position = { row: rowPath[i], column: columnPath[j] };
     const tile = this.grid.get(position);
     if (tile) {
       // 当此位置有Tile的时候才进行移动
       // 移动时,首先获取目标移动位置
       const { aim, next } = this.getNearestAvaibleAim(position, direction);
       this.moveTile(tile, aim);
       moved = true;
     }
   }
 }

 // 移动以后进行重新渲染
 if (moved) {
   this.render.render(this.grid);
 }
};

Render渲染器渲染时,扫描Grid中所有的Tile,动态生成 class。因此我们只需要改变Grid中Tile的
元素位置,页面当然重新渲染。思路如下:
Tile对应的Grid原始位置设置为 null
更新Tile的 position
将更新后的Tile设置到Grid新的位置

// manager.js

// 移动Tile,先将grid中老位置删除,在添加新位置
Manager.prototype.moveTile = function(tile, aim) {
  this.grid.cells[tile.row][tile.column] = null;
  tile.updatePosition(aim);
  this.grid.cells[aim.row][aim.column] = tile;
};

// tile.js

// 更新Tile的位置
Tile.prototype.updatePosition = function(position) {
  this.row = position.row;
  this.column = position.column;
};

监听联调:
在Listener监听回调中调用listenerFn方法

let self = this;
this.listener = new Listener({
  move: function(direction) {
    self.listenerFn(direction);
  }
});

为什么要定义let self = this?1
这涉及到JS 作用域,因为回调函数 function(direction) 是由Listener调用的,因此this会指向
Listener,并不是Manager。在这种情况下,如果使用this.listenerFn将无法找到listenerFn方法,
因此我们需要在方法调用之前(this还未改变之前)将this先保存到self。

render:

// render.js

// 渲染整个grid, 在之前先清空所有的Tile
Render.prototype.render = function(grid) {
  this.empty();
  ...
};

Render.prototype.empty = function() {
  this.tileContainer.innerHTML = '';
};

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-67M2H056-1604221108567)(https://style.youkeda.com/img/course/f10/4/3.svg)]

第五章 2048合并处理

5.1 合并处理

tile什么时候合并:方格移动到不能移动为止,并且下一个位置的 value 值和该方格 value 值一样。

// 寻找移动方向目标位置
Manager.prototype.getNearestAvaibleAim = function(aim, direction) {
  //...
  return {
    aim,
    next
  };
};

tile合并代码

// 移动核心逻辑
Manager.prototype.listenerFn = function(direction) {
  // 定义一个变量,判断是否引起移动
  let moved = false;

  const { rowPath, columnPath } = this.getPaths(direction);
  for (let i = 0; i < rowPath.length; i++) {
    for (let j = 0; j < columnPath.length; j++) {
      const position = { row: rowPath[i], column: columnPath[j] };
      const tile = this.grid.get(position);
      if (tile) {
        // 当此位置有Tile的时候才进行移动
        const { aim, next } = this.getNearestAvaibleAim(position, direction);

        // 区分合并和移动,当next值和tile值相同的时候才进行合并
        if (next && next.value === tile.value) {
          // 合并位置是next的位置,合并的value是tile.value * 2
          const merged = new Tile(
            {
              row: next.row,
              column: next.column
            },
            tile.value * 2
          );
          //将合并以后节点,加入grid
          this.grid.add(merged);
          //在grid中删除原始的节点
          this.grid.remove(tile);
          moved = true;
        } else {
          this.moveTile(tile, aim);
          moved = true;
        }
      }
    }
  }

  // 移动以后进行重新渲染
  if (moved) {
    this.render.render(this.grid);
  }
};

5.2 完善游戏步骤

tile合并后置逻辑;
Tile 合并或移动之后,游戏还得继续,因此每次移动之后,我们让游戏随机再次生成一个Tile。
随机生成Tile的代码,在初始化的时候已经实现过了,我们需要将这段代码抽离成一个函数,代码如下:

//manager.js

// 随机添加一个节点
Manager.prototype.addRandomTile = function() {
  const position = this.grid.randomAvailableCell();
  if (position) {
    // 90%概率为2,10%为4
    const value = Math.random() < 0.9 ? 2 : 4;
    // 随机一个方格的位置
    const position = this.grid.randomAvailableCell();
    // 添加到grid中
    this.grid.add(new Tile(position, value));
  }
};

修改调用区域代码,如下:
// manager.js
Manager.prototype.start = function() {
  for (let i = 0; i < 2; i++) {
    this.addRandomTile();
  }
  this.render.render(this.grid);
};

// 移动核心逻辑
Manager.prototype.listenerFn = function(direction) {
  // ...
  if (moved) {
    this.addRandomTile();
    this.render.render(this.grid);
  }
};

第六章 2048动画效果

###6.1 移动动画
回顾一下之前的代码逻辑
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QioNI8yS-1604221108568)(https://style.youkeda.com/img/course/f10/6/1.svg)]
方块移动动画:
1 使用CSS特性transition: transform 100ms ease-in-out,给transform加入动画效果。
2 因为我们的每个Tile节点是临时创建的,并不会出现class切换的效果,当然也不会出现transform值变化过程,
无法使用动画。我们可以使用一个猥琐逻辑,首先将Tileclass 设置为原始位置,然后延迟设置为当前位置。
示例:

开始时Tile在 1 行 1 列,16ms 后位置变成了 1 行 4 列
const div = document.createElement('div');
div.setAttribute('class', 'tile-position-1-1');
setTimeout(() => {
 div.setAttribute('class', 'tile-posiiton-1-4');
}, 16);

代码实现:

// tile.js
function Tile(position, value) {
  this.row = position.row;
  this.column = position.column;
  this.value = value;

  // 新增prePosition属性
  this.prePosition = null;
}

Tile.prototype.updatePosition = function(position) {
  // 更新的时候,先将当前位置,保存为prePosition
  this.prePosition = { row: this.row, column: this.column };

  this.row = position.row;
  this.column = position.column;
};

6.2 移动动画(二)

为了在merge的时候也保留移动动画,我们需要保留merge的两个原始Tile,才能实现方块移动效果。
我们继续在Tile里增加属性,代码如下:

// tile.js
function Tile(position, value) {
  this.row = position.row;
  this.column = position.column;
  this.value = value;

  // 新增prePosition属性
  this.prePosition = null;
  // 存储merged两个Tile
  this.mergedTiles = null;
}

// 移动核心逻辑
Manager.prototype.listenerFn = function(direction) {
  //...
  if (next && next.value === tile.value) {
    // 合并位置是next的位置,合并的value是tile.value * 2
    const merged = new Tile(
      {
        row: next.row,
        column: next.column
      },
      tile.value * 2
    );
    this.score += merged.value;

    //...
    if (merged.value === this.aim) {
      this.status = 'WIN';
    }

    // 特别注意下面两句话
    merged.mergedTiles = [tile, next];
    tile.updatePosition({ row: next.row, column: next.column });
    moved = true;
  }

  // ...
};

第七章 2048 储存

###7.1 本地储存
我们先来看几个问题:
1 有哪些信息需要被储存?
当前分数 最高分数 当前方格面板中的每一个方格数字
2在什么时候进行保存
在每一次移动之后,渲染之前进行保存。

//manager.js

Manager.prototype._render = function() {
  // 添加在此处进行处理

  this.render.render(this.grid, { score: this.score, status: this.status });
};

3 用什么技术进行保存?
window.localStorage
4 应该在什么时候恢复进度?
当页面重新加载的时候,初始化的时候,如果有历史进度,则加载历史进度

// 历史最高分
const BestScoreKey = '2048BestScore';
// 方格状态 和 分数
const CellStateKey = '2048CellState';

function Storage() {}

Storage.prototype.setCellState = function({ score, grid }) {
  // 存储方格状态 和 分数
};

Storage.prototype.getCellState = function() {
  // 获取方格状态
};

7.2本地存储(二)

序列化和反序列化
将对象信息变成字符串信息,我们通常叫做序列化。在这里我们分两步进行:
1将grid变成通用的json格式
2利用Json.stringify()将json序列化为字符串

//tile.js
Tile.prototype.serialize = function() {
  return {
    position: {
      row: this.row,
      column: this.column
    },
    value: this.value
  };
};

//grid.js

Grid.prototype.serialize = function() {
  const cellState = [];

  // cellState 是一个二维数组,分别存储整个Grid信息。
  // 如果该位置有Tile, 则返回 Tile序列化结果
  // 如果该位置没有Tile,则存储null
  for (let row = 0; row < this.size; row++) {
    cellState[row] = [];
    for (let column = 0; column < this.size; column++) {
      cellState[row].push(
        this.cells[row][column] ? this.cells[row][column].serialize() : null
      );
    }
  }

  return {
    size: this.size,
    cells: cellState
  };
};


反序列化
function Grid(size = 4, state) {
  this.size = size;
  this.cells = this.init(size);

  // 如果有之前的进度,则恢复
  if (state) {
    this.recover(state);
  }
}

Grid.prototype.recover = function({ size, cells }) {
  this.size = size;
  // 遍历这个二维数组,如果某个cell存在,则新建一个Tile节点。
  for (let row = 0; row < this.size; row++) {
    for (let column = 0; column < this.size; column++) {
      const cell = cells[row][column];
      if (cell) {
        this.cells[row][column] = new Tile(cell.position, cell.value);
      }
    }
  }

历史进度流程:

const CellStateKey = '2048CellState';

//...

// 存储方格状态和分数
Storage.prototype.setCellState = function({ score, grid }) {
  window.localStorage.setItem(
    CellStateKey,
    JSON.stringify({
      score,
      grid: grid.serialize()
    })
  );
};

// 获取方格信息
Storage.prototype.getCellState = function() {
  const cellState = window.localStorage.getItem(CellStateKey);
  return cellState ? JSON.parse(cellState) : null;
};

function Manager(size = 4, aim = 2048) {
  //...
  // 新增storage属性
  this.storage = new Storage();
  //...
}

Manager.prototype._render = function() {
  // 渲染之前调用存储
  this.storage.setCellState({ score: this.score, grid: this.grid });
  this.render.render(this.grid, { score: this.score, status: this.status });
};

// manager.js
Manager.prototype.defaultStart = function() {
  const state = this.storage.getCellState();
  // 如果存在缓存则恢复
  if (state) {
    this.score = state.score;
    this.status = 'DOING';
    this.grid = new Grid(this.size, state.grid);
    this._render();
  } else {
    this.start();
  }
};

第八章 项目完整代码

html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>优课达-2048</title>
    <link rel="stylesheet" href="./style/index.css" />
  </head>
  <body>
    <div class="container">
      <nav>
        <h1>2048</h1>
        <div class="score">
          <div class="now">
            <span class="label">SCORE</span>
            <span class="value">0</span>
          </div>
          <div class="best">
            <span class="label">BEST</span>
            <span class="value">0</span>
          </div>
        </div>
      </nav>
      <div class="desc">
        <p>
          <strong>Play 2048 Game online</strong>Join the numbers and get to the
          <strong>2048 tile!</strong>
        </p>
        <button>New Game</button>
      </div>
      <main>
        <div class="game-grid">
          <div class="grid-row">
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
          </div>
          <div class="grid-row">
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
          </div>
          <div class="grid-row">
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
          </div>
          <div class="grid-row">
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
            <div class="grid-cell"></div>
          </div>
        </div>
        <div class="tile-container">
          <!-- <div class="tile tile-2 tile-position-1-1">
            <div class="tile-inner">2</div>
          </div>
          <div class="tile tile-4 tile-position-1-2">
            <div class="tile-inner">4</div>
          </div>
          <div class="tile tile-8 tile-position-1-3">
            <div class="tile-inner">8</div>
          </div>
          <div class="tile tile-16 tile-position-1-4">
            <div class="tile-inner">16</div>
          </div>
          <div class="tile tile-32 tile-position-2-1">
            <div class="tile-inner">32</div>
          </div>
          <div class="tile tile-64 tile-position-2-2">
            <div class="tile-inner">64</div>
          </div>
          <div class="tile tile-128 tile-position-2-3">
            <div class="tile-inner">128</div>
          </div>
          <div class="tile tile-256 tile-position-2-4">
            <div class="tile-inner">256</div>
          </div>
          <div class="tile tile-512 tile-position-3-1">
            <div class="tile-inner">512</div>
          </div>
          <div class="tile tile-1024 tile-position-3-2">
            <div class="tile-inner">1024</div>
          </div>
          <div class="tile tile-2048 tile-position-3-3">
            <div class="tile-inner">2048</div>
          </div> -->
        </div>
      </main>
      <footer>
        <img src="./images/logo.png" />
        <span>出品</span>
      </footer>
      <div class="mask status">
        <div class="content">Game Over!</div>
        <button>Try again</button>
      </div>
    </div>
    <script src="./scripts/tile.js"></script>
    <script src="./scripts/grid.js"></script>
    <script src="./scripts/render.js"></script>
    <script src="./scripts/listener.js"></script>
    <script src="./scripts/storage.js"></script>
    <script src="./scripts/manager.js"></script>
    <script src="./scripts/index.js"></script>
  </body>
</html>

scss

//desc.scc
.desc {
  display: flex;
  padding: 0 42px;
  align-items: center;

  p {
    font-size: 15px;
    color: #635545;
    flex: 1;
  }

  button {
    margin: 0;
    padding: 0;
    width: 98px;
    height: 44px;
    border-radius: 4px;
    background-color: #8f7a66;
    font-size: 14px;
    color: #fff;
    line-height: 44px;
    text-align: center;
    font-weight: 700;
    font-family: Arial-Black;
  }
}


// index.scss
p {
  padding: 0;
  margin: 0;
}

button {
  cursor: pointer;
  &:focus {
    outline: none;
  }
}

@import './nav.scss';
@import './desc.scss';
@import './main.scss';

.body {
  margin: 0;
  padding: 0;
  font-family: 'Clear Sans', 'Helvetica Neue', Arial, sans-serif;
  color: #776e65;
}

.container {
  position: relative;
  width: 375px;
  height: 667px;
  position: fixed;
  background-color: #faf8ef;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

footer {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  margin-top: 78px;

  img {
    width: 100px;
    height: 36px;
  }

  span {
    margin-left: 16px;
    font-size: 14px;
    color: #8f7a67;
    font-weight: 500;
  }
}

.mask {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background-color: rgba(255, 255, 255, 0.4);

  .content {
    font-family: Arial-Black;
    font-size: 26px;
    color: #8f7a67;
    text-align: center;
  }

  button {
    margin-top: 20px;
    width: 98px;
    height: 44px;
    background: #8f7a67;
    border-radius: 4px;
    font-size: 14px;
    color: #ffffff;
    text-align: center;
    font-family: Arial-Black;
    font-weight: 700;
    line-height: 44px;
  }
}


//main.scss
$field-width: 290px;
$grid-spacing: 10px;
$grid-row-cells: 4;
$tile-size: ($field-width - $grid-spacing * ($grid-row-cells + 1)) /
  $grid-row-cells;
$tile-border-radius: 3px;

main {
  margin-top: 20px;
  margin-left: auto;
  margin-right: auto;
  box-sizing: border-box;
  width: $field-width;
  height: $field-width;
  position: relative;
  padding: $grid-spacing;
  background: #bbada0;
  border-radius: 8px;

  .game-grid {
    .grid-row {
      margin-bottom: $grid-spacing;
      display: flex;

      .grid-cell {
        width: $tile-size;
        height: $tile-size;
        margin-right: $grid-spacing;
        float: left;
        border-radius: 3px;
        background: rgba(238, 228, 218, 0.35);

        &:last-child {
          margin-right: 0;
        }
      }

      &:last-child {
        margin-bottom: 0;
      }
    }
  }

  .tile-container {
    position: absolute;
    left: 0;
    top: 0;

    .tile {
      position: absolute;
      width: $tile-size;
      height: $tile-size;
      border-radius: 4px;
      transition: transform 100ms ease-in-out;
    }

    .tile-inner {
      width: 100%;
      height: 100%;
      line-height: $tile-size;
      background: #eee4da;
      text-align: center;
      font-weight: bold;
      font-size: 34px;
      color: #776e65;
    }

    @for $x from 1 through $grid-row-cells {
      @for $y from 1 through $grid-row-cells {
        .tile-position-#{$x}-#{$y} {
          $xPos: $grid-spacing + floor(($tile-size + $grid-spacing) * ($y - 1));
          $yPos: $grid-spacing + floor(($tile-size + $grid-spacing) * ($x - 1));
          transform: translate($xPos, $yPos);
        }
      }
    }

    .tile-merged .tile-inner {
      z-index: 20;
      animation: pop 200ms ease 100ms;
      animation-fill-mode: backwards;
    }

    .tile-new .tile-inner {
      animation: appear 200ms ease-in-out;
      animation-delay: 100ms;
      animation-fill-mode: backwards;
    }

    .tile.tile-2 .tile-inner {
      background: #eee4da;
    }
    .tile.tile-4 .tile-inner {
      background: #ede0c8;
    }
    .tile.tile-8 .tile-inner {
      color: #f9f6f2;
      background: #f2b179;
    }
    .tile.tile-16 .tile-inner {
      color: #f9f6f2;
      background: #f59563;
    }
    .tile.tile-32 .tile-inner {
      color: #f9f6f2;
      background: #f67c5f;
    }
    .tile.tile-64 .tile-inner {
      color: #f9f6f2;
      background: #f65e3b;
    }
    .tile.tile-128 .tile-inner {
      color: #f9f6f2;
      background: #edcf72;
      font-size: 30px;
    }

    .tile.tile-256 .tile-inner {
      color: #f9f6f2;
      background: #edcc61;
      font-size: 30px;
    }

    .tile.tile-512 .tile-inner {
      color: #f9f6f2;
      background: #edc850;
      font-size: 30px;
    }

    .tile.tile-1024 .tile-inner {
      color: #f9f6f2;
      background: #edc53f;
      font-size: 22px;
    }

    .tile.tile-2048 .tile-inner {
      color: #f9f6f2;
      background: #edc22e;
      font-size: 22px;
    }
  }
}

@keyframes appear {
  0% {
    opacity: 0;
    transform: scale(0);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes pop {
  0% {
    transform: scale(0);
  }

  50% {
    transform: scale(1.2);
  }

  100% {
    transform: scale(1);
  }
}

//nav.scss
nav {
  height: 68px;
  padding: 30px 42px;
  display: flex;
  justify-content: space-between;
  align-items: center;

  h1 {
    margin: 0;
    font-size: 34px;
    font-weight: 700px;
    color: #635545;
  }
  .score {
    display: flex;
    > div {
      width: 68px;
      height: 68px;
      margin-left: 10px;
      border-radius: 6px;
      background-color: #bbada0;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;

      .label {
        font-size: 15px;
        font-weight: bold;
        color: #eee4d9;
      }

      .value {
        font-size: 28px;
        font-weight: 700;
        color: #ffffff;
      }
    }
  }
}

js

//grid.js
//grid.js
function Grid(size = 4, state) {
  this.size = size;
  this.cells = [];
  this.init(size);
  // 如果有之前的进度,则恢复
  if (state) {
    this.recover(state);
  }
}

Grid.prototype.recover = function({ size, cells }) {
  this.size = size;
  // 遍历这个二维数组,如果某个cell存在,则新建一个Tile节点。
  for (let row = 0; row < this.size; row++) {
    for (let column = 0; column < this.size; column++) {
      const cell = cells[row][column];
      if (cell) {
        this.cells[row][column] = new Tile(cell.position, cell.value);
      }
    }
  }
};

// prototype 设置方法
Grid.prototype.init = function(size) {
  for (let row = 0; row < size; row++) {
    this.cells.push([]);
    for (let column = 0; column < size; column++) {
      this.cells[row].push(null);
    }
  }
};

Grid.prototype.add = function(tile) {
  this.cells[tile.row][tile.column] = tile;
};

Grid.prototype.remove = function(tile) {
  this.cells[tile.row][tile.column] = null;
};

// 获取所有可用方格的位置
Grid.prototype.availableCells = function() {
  const availableCells = [];
  for (let row = 0; row < this.cells.length; row++) {
    for (let column = 0; column < this.cells[row].length; column++) {
      // 如果当前方格没有内容,则其可用(空闲)
      if (!this.cells[row][column]) {
        availableCells.push({ row, column });
      }
    }
  }
  return availableCells;
};

// 随机获取某个可用方格的位置
Grid.prototype.randomAvailableCell = function() {
  // 获取到所有的空闲方格
  const cells = this.availableCells();
  if (cells.length > 0) {
    // 利用Math.random()随机获取其中的某一个
    return cells[Math.floor(Math.random() * cells.length)];
  }
};

// 获取某个位置的Tile
Grid.prototype.get = function(position) {
  if (this.outOfRange(position)) {
    return null;
  }
  return this.cells[position.row][position.column];
};

// 判断某个位置是否超出边界
Grid.prototype.outOfRange = function(position) {
  return (
    position.row < 0 ||
    position.row >= this.size ||
    position.column < 0 ||
    position.column >= this.size
  );
};

Grid.prototype.serialize = function() {
  const cellState = [];

  // cellState 是一个二维数组,分别存储整个Grid信息。
  // 如果该位置有Tile, 则返回 Tile序列化结果
  // 如果该位置没有Tile,则存储null
  for (let row = 0; row < this.size; row++) {
    cellState[row] = [];
    for (let column = 0; column < this.size; column++) {
      cellState[row].push(
        this.cells[row][column] ? this.cells[row][column].serialize() : null
      );
    }
  }

  return {
    size: this.size,
    cells: cellState
  };
};


//index.js
new Manager();


// listener.js
function Listener({ move: moveFn, start: startFn }) {
  window.addEventListener('keyup', function(e) {
    switch (e.keyCode) {
      case 38:
        moveFn({ row: -1, column: 0 });
        break;
      case 37:
        moveFn({ row: 0, column: -1 });
        break;
      case 39:
        moveFn({ row: 0, column: 1 });
        break;
      case 40:
        moveFn({ row: 1, column: 0 });
        break;
    }
  });

  const buttons = document.querySelectorAll('button');
  for (let i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
      startFn();
    });
  }
}



//manager.js
function Manager(size = 4, aim = 8) {
  this.size = size;
  this.aim = aim;
  this.render = new Render();
  this.storage = new Storage();
  let self = this;
  this.listener = new Listener({
    move: function(direction) {
      self.listenerFn(direction);
    },
    start: function() {
      self.start();
    }
  });
  this.defaultStart();
}

Manager.prototype.defaultStart = function() {
  const state = this.storage.getCellState();
  let bestScore = this.storage.getBestScore();
  if (!bestScore) {
    bestScore = 0;
  }
  this.bestScore = bestScore;
  // 如果存在缓存则恢复
  if (state) {
    this.score = state.score;
    this.status = 'DOING';
    this.grid = new Grid(this.size, state.grid);
    this._render();
  } else {
    this.start();
  }
};

Manager.prototype.start = function() {
  this.score = 0;
  this.status = 'DOING';
  this.grid = new Grid(this.size);
  for (let i = 0; i < 2; i++) {
    //初始化
    this.addRandomTile();
  }
  this._render();
};

Manager.prototype._render = function() {
  // 渲染之前调用存储
  this.storage.setCellState({ score: this.score, grid: this.grid });
  if (this.score > this.bestScore) {
    this.bestScore = this.score;
    this.storage.setBestScore(this.bestScore);
  }
  this.render.render(this.grid, {
    score: this.score,
    status: this.status,
    bestScore: this.bestScore
  });
};

// 随机添加一个节点
Manager.prototype.addRandomTile = function() {
  const position = this.grid.randomAvailableCell();
  if (position) {
    // 90%概率为2,10%为4
    const value = Math.random() < 0.9 ? 2 : 4;
    // 随机一个方格的位置
    const position = this.grid.randomAvailableCell();
    // 添加到grid中
    this.grid.add(new Tile(position, value));
  }
};

// 移动核心逻辑
Manager.prototype.listenerFn = function(direction) {
  // 定义一个变量,判断是否引起移动
  let moved = false;

  const { rowPath, columnPath } = this.getPaths(direction);
  for (let i = 0; i < rowPath.length; i++) {
    for (let j = 0; j < columnPath.length; j++) {
      const position = { row: rowPath[i], column: columnPath[j] };
      const tile = this.grid.get(position);
      if (tile) {
        // 当此位置有Tile的时候才进行移动
        const { aim, next } = this.getNearestAvaibleAim(position, direction);

        // 区分合并和移动,当next值和tile值相同的时候才进行合并
        if (next && next.value === tile.value) {
          // 合并位置是next的位置,合并的value是tile.value * 2
          const merged = new Tile(
            {
              row: next.row,
              column: next.column
            },
            tile.value * 2
          );

          this.score += merged.value;
          //将合并以后节点,加入grid
          this.grid.add(merged);
          //在grid中删除原始的节点
          this.grid.remove(tile);
          //判断游戏是否获胜
          if (merged.value === this.aim) {
            this.status = 'WIN';
          }
          merged.mergedTiles = [tile, next];
          tile.updatePosition({ row: next.row, column: next.column });
          moved = true;
        } else {
          this.moveTile(tile, aim);
          moved = true;
        }
      }
    }
  }

  // 移动以后进行重新渲染
  if (moved) {
    this.addRandomTile();
    if (this.checkFailure()) {
      this.status = 'FAILURE';
    }
    this._render();
  }
};

// 移动Tile,先将grid中老位置删除,在添加新位置
Manager.prototype.moveTile = function(tile, aim) {
  this.grid.cells[tile.row][tile.column] = null;
  tile.updatePosition(aim);
  this.grid.cells[aim.row][aim.column] = tile;
};

// 根据方向,确定遍历的顺序
Manager.prototype.getPaths = function(direction) {
  let rowPath = [];
  let columnPath = [];
  for (let i = 0; i < this.size; i++) {
    rowPath.push(i);
    columnPath.push(i);
  }

  // 向右的时候
  if (direction.column === 1) {
    columnPath = columnPath.reverse();
  }

  // 向下的时候
  if (direction.row === 1) {
    rowPath = rowPath.reverse();
  }
  return {
    rowPath,
    columnPath
  };
};

// 寻找移动方向目标位置
Manager.prototype.getNearestAvaibleAim = function(aim, direction) {
  // 位置 + 方向向量的计算公式
  function addVector(position, direction) {
    return {
      row: position.row + direction.row,
      column: position.column + direction.column
    };
  }
  aim = addVector(aim, direction);

  // 获取grid中某个位置的元素
  let next = this.grid.get(aim);

  // 如果next元素存在(也就是此目标位置已经有Tile),或者是超出游戏边界,则跳出循环。目的:就是找到最后一个空白且不超过边界的方格
  while (!this.grid.outOfRange(aim) && !next) {
    aim = addVector(aim, direction);
    next = this.grid.get(aim);
  }

  // 这时候的aim总是多计算了一步,因此我们还原一下
  aim = {
    row: aim.row - direction.row,
    column: aim.column - direction.column
  };

  return {
    aim,
    next
  };
};

// 判断游戏是否失败
Manager.prototype.checkFailure = function() {
  // 获取空白的Cell
  const emptyCells = this.grid.availableCells();
  // 如果存在空白,则游戏肯定没有失败
  if (emptyCells.length > 0) {
    return false;
  }

  for (let row = 0; row < this.grid.size; row++) {
    for (let column = 0; column < this.grid.size; column++) {
      let now = this.grid.get({ row, column });

      // 根据4个方向,判断临近的Tile的Value值是否相同
      let directions = [
        { row: 0, column: 1 },
        { row: 0, column: -1 },
        { row: 1, column: 0 },
        { row: -1, column: 0 }
      ];
      for (let i = 0; i < directions.length; i++) {
        const direction = directions[i];
        const next = this.grid.get({
          row: row + direction.row,
          column: column + direction.column
        });
        // 判断Value是否相同
        if (next && next.value === now.value) {
          return false;
        }
      }
    }
  }
  return true;
};

//render.js
//render.js
function Render() {
  this.tileContainer = document.querySelector('.tile-container');
  this.scoreContainer = document.querySelector('.now .value');
  this.statusContainer = document.querySelector('.status');
  this.bestScoreContainer = document.querySelector('.best .value');
}

// 渲染整个grid
Render.prototype.render = function(grid, { score, status, bestScore }) {
  this.empty();
  this.renderScore(score);
  this.renderBestScore(bestScore);
  this.renderStatus(status);
  for (let row = 0; row < grid.size; row++) {
    for (let column = 0; column < grid.size; column++) {
      // 如果grid中某个cell不为空,则渲染这个cell
      if (grid.cells[row][column]) {
        this.renderTile(grid.cells[row][column]);
      }
    }
  }
};

Render.prototype.renderBestScore = function(bestScore) {
  this.bestScoreContainer.innerHTML = bestScore;
};

Render.prototype.renderScore = function(score) {
  this.scoreContainer.innerHTML = score;
};

Render.prototype.renderStatus = function(status) {
  if (status === 'DOING') {
    this.statusContainer.style.display = 'none';
    return;
  }
  this.statusContainer.style.display = 'flex';
  this.statusContainer.querySelector('.content').innerHTML =
    status === 'WIN' ? 'You Win!' : 'Game Over!';
};

// 清空tileContainer
Render.prototype.empty = function() {
  this.tileContainer.innerHTML = '';
};

// 渲染单个tile
Render.prototype.renderTile = function(tile) {
  // 创建一个tile-inner
  const tileInner = document.createElement('div');
  tileInner.setAttribute('class', 'tile-inner');
  tileInner.innerHTML = tile.value;

  // 创建一个tile
  const tileDom = document.createElement('div');
  let classList = [
    'tile',
    `tile-${tile.value}`,
    `tile-position-${tile.row + 1}-${tile.column + 1}`
  ];

  if (tile.prePosition) {
    // 先设置之前的位置
    classList[2] = `tile-position-${tile.prePosition.row + 1}-${tile.prePosition
      .column + 1}`;
    // 延迟设置当前的位置
    setTimeout(function() {
      classList[2] = `tile-position-${tile.row + 1}-${tile.column + 1}`;
      tileDom.setAttribute('class', classList.join(' '));
    }, 16);
  } else if (tile.mergedTiles) {
    classList.push('tile-merged');
    //如果有mergedTiles,则渲染mergedTile的两个Tile
    tileDom.setAttribute('class', classList.join(' '));
    for (let i = 0; i < tile.mergedTiles.length; i++) {
      this.renderTile(tile.mergedTiles[i]);
    }
  } else {
    classList.push('tile-new');
  }

  tileDom.setAttribute('class', classList.join(' '));
  tileDom.appendChild(tileInner);
  this.tileContainer.appendChild(tileDom);
};


//storage.js
// 历史最高分
const BestScoreKey = '2048BestScore';
// 方格状态和分数
const CellStateKey = '2048CellState';

function Storage() {}

Storage.prototype.setBestScore = function(bestScore) {
  window.localStorage.setItem(BestScoreKey, bestScore);
};

Storage.prototype.getBestScore = function() {
  return window.localStorage.getItem(BestScoreKey);
};

// 存储方格状态和分数
Storage.prototype.setCellState = function({ score, grid }) {
  window.localStorage.setItem(
    CellStateKey,
    JSON.stringify({
      score,
      grid: grid.serialize()
    })
  );
};

// 获取方格信息
Storage.prototype.getCellState = function() {
  const cellState = window.localStorage.getItem(CellStateKey);
  return cellState ? JSON.parse(cellState) : null;
};


//tile.js
function Tile(position, value) {
  this.row = position.row;
  this.column = position.column;
  this.value = value;

  // 新增prePosition属性
  this.prePosition = null;
  // 存储merged两个Tile
  this.mergedTiles = null;
}

Tile.prototype.updatePosition = function(position) {
  // 更新的时候,先将当前位置,保存为prePosition
  this.prePosition = { row: this.row, column: this.column };

  this.row = position.row;
  this.column = position.column;
};

Tile.prototype.serialize = function() {
  return {
    position: {
      row: this.row,
      column: this.column
    },
    value: this.value
  };
};

  • 7
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值