第一章 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静态页面开发
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
};
};