项目源码:GitHub - Atopos-suyu/256game: 这是改编 的2048小游戏,为防止有人通关不成功,让他们体验下通关的满足感,写了256小游戏
2048设计稿:腾讯 CoDesign - 腾讯 CoDesign
页面效果展示:
游戏开始界面:
游戏结束页面:
游戏胜利页面:
实战需求:
1.游戏是一个4x4的方格,每个方格我们称作为一个Tile或者Cel。
2.游戏开始能随机出现2个Tile,每个的值90%可能为2,10%可能为4。
3.可以通过上、下、左、右键盘操作,每个Tle按照方向移动到不可移动为止。
4.如果移动以后两个Tile的内容值一样,则进行合并。
5.每个Tile移动会有100ms的移动动画。
6.每个Tile的出现有个短暂的放大效果。
7.每次Tile的合并有个短暂的放大回弹效果。
8.J顶部Score记录当前分数,BestScore记录有史以来最高
分,每次合并都会产生分数的变化,分数计算规则为:分数
=原来分数+合并后的值。
9.游戏将时时刻刻记录进度,刷新页面重现游戏进度。
10.当某个Tile的值为2048,游戏胜利。
11.当每个方格都有值,并且相邻两个方格无法再进行合并,则游戏结束。
实战技术知识点:
1.静态页面渲染:需要HTML、CSS基础知识,包括学习的SCSS知识。
2.开始游戏等事件处理:需要使用DOM监听事件。
3.Tle移动处理:需要监听键盘事件(暂时不处理H5中手势事件的情况)。
4.Tile动态随机添加:需要使用DOM动态操作。
5.Tile移动,合并:需要使用Javascript列表,对象,方法等数据结构和常用技巧。
6.Tile动画:需要使用CSS的transform和animation等动画效果。
7.本地缓存:需要使用Javascript localStorage浏览器缓存。
8…
静态页面开发
2048静态页面一:
<!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>...</nav>
<div class="desc">...</div>
<main>
<div class="game-grid">...</div>
<div class="tile-container">...</div>
</main>
<footer>...</footer>
</div>
</body>
</html>
整个页面的CSS文件较大,为了更加清晰的理解CSS文件。我们利用scss@import特性对文件进行分离,如下文件目录。
|-- images
|-- style
|-- index.scss // scss入口文件 + footer
|-- nav.scss // 头部区域文件
|-- main.scss // 主体区域文件
|-- desc.scss // 描述区域文件
|-- index.html
$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 {
.grid-cell {
}
}
}
}
静态页面二
为了方便以后Tile方块动态渲染,我们加入2、4、8到2048,11个方块静态页面,最终效果如下:
我们分析下其相同点和不同点:
1.它们都有一样的大小,圆角,动效。所以我们需要设置一个统一的class为tile
2.它们每个数字颜色和字体大小都不同,因此我们需要为每个值设置单独的样式,class为ti1e-(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用于元素放缩。
<div class="tile tile-2 tile-position-1-1">
<div class="tile-inner">2</div>
</div>
.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;
}
//通过scss的循环和变量,动态生成4行4列的Tile Position样式
@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.tile-2 .tile-inner {
background: #eee4da;
}
}
<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 .tile-container {
position: absolute;
left: 0;
top: 0;
}
main .tile-container .tile {
position: absolute;
width: 60px;
height: 60px;
border-radius: 4px;
transition: transform 100ms ease-in-out;
}
main .tile-container .tile-inner {
width: 100%;
height: 100%;
line-height: 60px;
background: #eee4da;
text-align: center;
font-weight: bold;
font-size: 34px;
color: #776e65;
}
main .tile-container .tile-position-1-1 {
transform: translate(10px, 10px);
}
main .tile-container .tile-position-1-2 {
transform: translate(80px, 10px);
}
main .tile-container .tile-position-1-3 {
transform: translate(150px, 10px);
}
main .tile-container .tile-position-1-4 {
transform: translate(220px, 10px);
}
main .tile-container .tile-position-2-1 {
transform: translate(10px, 80px);
}
main .tile-container .tile-position-2-2 {
transform: translate(80px, 80px);
}
main .tile-container .tile-position-2-3 {
transform: translate(150px, 80px);
}
main .tile-container .tile-position-2-4 {
transform: translate(220px, 80px);
}
main .tile-container .tile-position-3-1 {
transform: translate(10px, 150px);
}
main .tile-container .tile-position-3-2 {
transform: translate(80px, 150px);
}
main .tile-container .tile-position-3-3 {
transform: translate(150px, 150px);
}
main .tile-container .tile-position-3-4 {
transform: translate(220px, 150px);
}
main .tile-container .tile-position-4-1 {
transform: translate(10px, 220px);
}
main .tile-container .tile-position-4-2 {
transform: translate(80px, 220px);
}
main .tile-container .tile-position-4-3 {
transform: translate(150px, 220px);
}
main .tile-container .tile-position-4-4 {
transform: translate(220px, 220px);
}
main .tile-container .tile-merged .tile-inner {
z-index: 20;
animation: pop 200ms ease 100ms;
animation-fill-mode: backwards;
}
main .tile-container .tile-new .tile-inner {
animation: appear 200ms ease-in-out;
animation-delay: 100ms;
animation-fill-mode: backwards;
}
main .tile-container .tile.tile-2 .tile-inner {
background: #eee4da;
}
main .tile-container .tile.tile-4 .tile-inner {
background: #ede0c8;
}
main .tile-container .tile.tile-8 .tile-inner {
color: #f9f6f2;
background: #f2b179;
}
main .tile-container .tile.tile-16 .tile-inner {
color: #f9f6f2;
background: #f59563;
}
main .tile-container .tile.tile-32 .tile-inner {
color: #f9f6f2;
background: #f67c5f;
}
main .tile-container .tile.tile-64 .tile-inner {
color: #f9f6f2;
background: #f65e3b;
}
main .tile-container .tile.tile-128 .tile-inner {
color: #f9f6f2;
background: #edcf72;
font-size: 30px;
}
main .tile-container .tile.tile-256 .tile-inner {
color: #f9f6f2;
background: #edcc61;
font-size: 30px;
}
main .tile-container .tile.tile-512 .tile-inner {
color: #f9f6f2;
background: #edc850;
font-size: 30px;
}
main .tile-container .tile.tile-1024 .tile-inner {
color: #f9f6f2;
background: #edc53f;
font-size: 22px;
}
main .tile-container .tile.tile-2048 .tile-inner {
color: #f9f6f2;
background: #edc22e;
font-size: 22px;
}
2048模型设计,随机渲染
2048对象设计
Tile对象
// tile.js
function Tile(position, value) {
this.row = position.row;
this.column = position.column;
this.value = value;
}
Grid对象
// grid.js
function Grid(size = 4) {
this.size = size;
}
//grid.js
function Grid(size = 4) {
this.size = size;
this.cells = [];
this.init(size);
}
// 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.js
function Grid(size = 4, state) {
this.size = size;
this.cells = [];
this.init(size); //调用init方法进行初始化
// 如果有之前的进度,则恢复
if (state) {
this.recover(state);
}
} //Grid对象表示一个游戏棋盘,大小为size*size的方格,参数state恢复游戏进度
Grid.prototype.recover = function({ size, cells }) {
this.size = size;
//通过遍历cells二维数组,如果某个单元格存在内容,则新建一个Tile对象并放在Grid对象的位置上
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);
}
}
}
};//recover方法用于恢复Grid对象的状态
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对象的初始化方法,创建指定大小的二维数组,遍历循环将每个位置的值初始化为null
Grid.prototype.add = function(tile) {
this.cells[tile.row][tile.column] = tile;
};//add方法将tile对象添加到网格的对应单元格,存储在对应位置的二维数组中
Grid.prototype.remove = function(tile) {
this.cells[tile.row][tile.column] = null;
};//remove方法用于从网格中移除tile对象,将对应单元格的值设置为null
// 获取Grid所有可用方格的位置
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 });
}//将这个单元格的行和列的值作为一个对象推入'availableCells'数组
}
}
return availableCells;//即所有空闲单元格的列表
};
//从给定的Grid对象中获取所有空闲方格并随机返回其中一个方格
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
);
};//满足以下任何条件,则判断true
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
);//如果该位置有tile则调用Tile对象的serialize方法将Tile序列化的结果存入cellState
}//如果没有则存储null
}
return {
size: this.size,
cells: cellState //cells表示存储了所有格子状态的二维数组
};
};
引入js
<html>
...
<body>
...
<script src="./scripts/tile.js"></script>
<script src="./scripts/grid.js"></script>
<script src="./scripts/index.js"></script>
</body>
</html>
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);
};
效果如下:
2048随机初始化
所有可用方格
// 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移动处理
重构-Manager
//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(this.grid);
};
键盘监听
window.addEventListener('keyup', function (e) {
console.log(e.code);
});
监听回调设置(监听器Listener)
function Listener() {
window.addEventListener('keyup', function (e) {
switch (e.code) {
case 'ArrowUp':
console.log('向上');
break;
case 'ArrowLeft':
console.log('向左');
break;
case 'ArrowRight':
console.log('向右');
break;
case 'ArrowDown':
console.log('向下');
break;
}
});
}
事件回调
//listener.js
function Listener({ move: moveFn }) {
window.addEventListener('keyup', function (e) {
switch (e.code) {
case 'ArrowLeft':
moveFn('向左');
break;
case 'ArrowUp':
moveFn('向上');
break;
case 'ArrowDown':
moveFn('向下');
break;
case 'ArrowRight':
moveFn('向右');
break;
}
});
}
Manager修改
//manager.js
this.listener = new Listener({
move: function (direction) {
console.log(direction);
},
});
方向向量化(方向上下左右)
左 => row: 1, column: 0 // 列数索引 -1, 也就是column -1
右 => row: 1, column: 2 // 列数索引 +1, 也就是column +1
上 => row: 0, column: 1 // 行数索引 -1, 也就是row -1
下 => row: 2, column: 1 // 行数索引 +1, 也就是row +1
//listener.js
function Listener({ move: moveFn }) {
window.addEventListener('keyup', function (e) {
switch (e.code) {
case 'ArrowUp':
moveFn({ row: -1, column: 0 });
break;
case 'ArrowLeft':
moveFn({ row: 0, column: -1 });
break;
case 'ArrowRight':
moveFn({ row: 0, column: 1 });
break;
case 'ArrowDown':
moveFn({ row: 1, column: 0 });
break;
}
});
}
移动位置计算
Tile移动
代码实现-遍历顺序
//manager.js
Manager.prototype.getPaths = function (direction) {
let rowPath = [];
let columnPath = [];
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);
}
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,
};
};
通过循环,找寻最后一个空白位置
// 如果next元素存在(也就是此目标位置已经有Tile,
// 或者是超出游戏边界,则跳出循环。
// 目的:就是找到最后一个空白且不超过边界的方格
while (!this.grid.outOfRange(aim) && !next) {
aim = addVector(aim, direction);
next = this.grid.get(aim);
}
Grid新增两个方法
// grid.js
// 获取某个位置的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
);
};
Tile移动
Tile移动处理
1.根据方向获取遍历顺序,跟随顺序进行遍历
2.遍历时候,如果此位置上有Tie,则进行移动
3.根据当前Tle的位置和方向,获取目标移动位置
4.进行Tile移动
5.只要有一个节点产生移动,则重新调用渲染器渲染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);
}
};
MoveFile处理
1.Tile对应的Grid原始位置设置为nul
2.更新Tile的position
3.将更新后的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);
}
});
这涉及到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 = '';
};
总结:最终类图
2048合并处理
Tile合并规则
规则只有一个:方格移动到不能移动为止,并且下一个位置的Vaue
值和该方格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);
}
};
新增Grid的remove方法,删除某个TIle
// grid.js
Grid.prototype.remove = function(tile) {
this.cells[tile.row][tile.column] = null;
};
完善游戏步骤
TIle合并后置逻辑
当T11e合并或移动之后,游戏还得继续,因此每次移动之后,我们让游戏随机再次生成一个Ti1e。
//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动画效果
移动动画(UML图)
方块移动动画
1.使用CSS特性transition:transform 100ms ease-in-out给transform加入动画效果。
2.因为我们的每个T11e节点是临时创建的,并不会出现class切换的效果,当然也不会出现transform值变化过程,无法使用动画。我们可以使用一个猥琐逻辑,首先将Tile class设置为原始位置,然后延迟设置为当前位置。
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;
};
// render.js
// 渲染单个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);
}
tileDom.setAttribute('class', classList.join(' '));
tileDom.appendChild(tileInner);
this.tileContainer.appendChild(tileDom);
};
.tile {
position: absolute;
width: $tile-size;
height: $tile-size;
border-radius: 4px;
/*transition 移动动画*/
transition: transform 100ms ease-in-out;
}
移动动画(二)
// 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;
}
// ...
};
// 特别注意下面两句话
merged.mergedTiles = [tile, next];
tile.updatePosition({ row: next.row, column: next.column });
我们提前存储merged行为发生的两个原始节点,并更新原始TiLe的位置,让其产生移动效果,最后继续修改渲染代码:
@keyframes appear {
0% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
2048本地存储
保存进度
1.想想有哪些信息需要被保存?
1.当前分数
2.历史最高分
3.当前的方格面板Grid,每一个方格里面的数字
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() {
// 获取方格状态
};
本地存储(二)
序列化和反序列化
将对象信息变成字符串信息,我们通常叫做序列化。在这里我们分两步进行:
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
//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);
}
}
}
};
历史进度流程(
1. 获取storage.js)存储/获取逻辑
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;
};
2. 完善listenerFn方法,每一步移动后,都进行存储。
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 });
};
3. 完善恢复方格状态的逻辑
3.1. manager.js
// 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();
}
};
3.2. grid.js
//grid.js
function Grid(size = 4, state) {
this.size = size;
this.cells = [];
this.init(size); //调用init方法进行初始化
// 如果有之前的进度,则恢复
if (state) {
this.recover(state);
}
} //Grid对象表示一个游戏棋盘,大小为size*size的方格,参数state恢复游戏进度
Grid.prototype.recover = function({ size, cells }) {
this.size = size;
//通过遍历cells二维数组,如果某个单元格存在内容,则新建一个Tile对象并放在Grid对象的位置上
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);
}
}
}
};//recover方法用于恢复Grid对象的状态
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对象的初始化方法,创建指定大小的二维数组,遍历循环将每个位置的值初始化为null
Grid.prototype.add = function(tile) {
this.cells[tile.row][tile.column] = tile;
};//add方法将tile对象添加到网格的对应单元格,存储在对应位置的二维数组中
Grid.prototype.remove = function(tile) {
this.cells[tile.row][tile.column] = null;
};//remove方法用于从网格中移除tile对象,将对应单元格的值设置为null
// 获取Grid所有可用方格的位置
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 });
}//将这个单元格的行和列的值作为一个对象推入'availableCells'数组
}
}
return availableCells;//即所有空闲单元格的列表
};
//从给定的Grid对象中获取所有空闲方格并随机返回其中一个方格
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
);
};//满足以下任何条件,则判断true
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
);//如果该位置有tile则调用Tile对象的serialize方法将Tile序列化的结果存入cellState
}//如果没有则存储null
}
return {
size: this.size,
cells: cellState //cells表示存储了所有格子状态的二维数组
};
};
3.3. listener.js
function Listener({ move: moveFn, start: startFn }) {//接受对象中的两个属性作为参数
window.addEventListener('keyup', function(e) {
switch (e.code) {//注册键盘监听器,按键后会触发指定的事件
case 'ArrowUp':
moveFn({ row: -1, column: 0 });
break;
case 'ArrowLeft':
moveFn({ row: 0, column: -1 });
break;
case 'ArrowRight':
moveFn({ row: 0, column: 1 });
break;
case 'ArrowDown':
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();
});
}//遍历所有的 <button> 元素,并为它们添加一个点击事件监听器
}//点击每个按钮时都会触发 startFn() 函数的执行
3.4. manage.js
function Manager(size = 4, aim = 256) {
this.size = size;
this.aim = aim;
this.render = new Render(); //处理游戏界面的渲染
this.storage = new Storage(); //处理游戏状态的存储
let self = this; //便于在后续的回调函数内部访问到正确的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);
//传入游戏大小和从 state.grid 中获取的游戏棋盘格子状态
//以恢复游戏棋盘
this._render(); //进行页面渲染,将恢复的游戏状态显示在界面上
} else {
this.start();
}
}; //根据存储的游戏状态或者新开始一个游戏,来进行默认的游戏初始化设置
Manager.prototype.start = function () {
this.score = 0; //计分板清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, {
//调用render属性的render方法
score: this.score,
status: this.status,
bestScore: this.bestScore,
});
}; //将游戏盘面(grid)的状态渲染到前端界面上
// 随机添加一个节点
Manager.prototype.addRandomTile = function () {
const position = this.grid.randomAvailableCell(); //获取可用的随机空白格子
if (position) {
// 90%概率为2,10%为4
const value =
Math.random() < 0.8
? 2
: 4
// 随机一个方格的位置
const position = this.grid.randomAvailableCell();
// 添加到grid中
this.grid.add(new Tile(position, value));
}
}; //该方法可在游戏盘面中随机生成一个新的数字方块
// 移动逻辑核心
Manager.prototype.listenerFn = function (direction) {
// 定义一个变量,判断是否引起移动,初始值为false移动则为true
let moved = false;
//根据移动方向获取路径rowPath和columnPath
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); //将方块位置更新为目标位置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();
} //将columnPath数组反转(倒序以便从右向左遍历游戏盘面的列
// 向下的时候
if (direction.row === 1) {
rowPath = rowPath.reverse();
} //将rowPath数组反转(倒序),以便从下往上遍历游戏盘面的行
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);
let next = this.grid.get(aim);
// 获取游戏盘面上新目标位置的元素
while (!this.grid.outOfRange(aim) && !next) {
aim = addVector(aim, direction);
next = this.grid.get(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;
};
3.5. 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");
} //构造函数 Render(),用于初始化游戏界面的相关元素
// 渲染整个游戏界面
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]);
}
}
} //调用 this.renderTile(grid.cells[row][column]) 方法进行渲染,将方块显示在游戏界面上
};
Render.prototype.renderBestScore = function (bestScore) {
this.bestScoreContainer.innerHTML = bestScore;
}; //用于渲染历史最高分,将传入的 bestScore 参数更新到界面上显示
Render.prototype.renderScore = function (score) {
this.scoreContainer.innerHTML = score;
}; //用于渲染当前得分,将传入的 score 参数更新到界面上显示
Render.prototype.renderStatus = function (status) {
if (status === "DOING") {
//表示游戏正在进行中,此时隐藏游戏状态容器
this.statusContainer.style.display = "none";
return;
} //用于渲染游戏状态,根据传入的 status 参数来显示相应的界面内容
this.statusContainer.style.display = "flex";
this.statusContainer.querySelector(".content").innerHTML =
status === "WIN" ? "You Win!" : "Game Over!";
}; //表示游戏结束。此时显示游戏状态容器,并根据 status 的值设置状态内容
// 清空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 = [
//数组中包含3个类名
"tile",
`tile-${tile.value}`, //根据方块的值动态生成,${}将包含变量或表达式
`tile-position-${tile.row + 1}-${tile.column + 1}`, //根据方块的行和列值加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); //将数组中的元素以空格分隔拼接成一个字符串:'tile tile-2 tile-position-3-4'
} 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]);
} //对合并的方块再调用renderTile方法进行渲染
} else {
classList.push("tile-new");
}
tileDom.setAttribute("class", classList.join(" "));
tileDom.appendChild(tileInner); //将tileInner添加为tileDom的子元素
this.tileContainer.appendChild(tileDom);
}; //这个方法通过创建和设置不同的类名来渲染方块的位置、合并状态和新创建状态
//并将渲染后的方块元素添加到指定的容器中
3.6. storage.js
// 历史最高分
const BestScoreKey = "2048BestScore";
// 方格状态和分数
const CellStateKey = "2048CellState";
function Storage() {}
//为 Storage 对象提供构造函数,并为该构造函数创建的对象实例提供方法
Storage.prototype.setBestScore = function (bestScore) {
window.localStorage.setItem(BestScoreKey, bestScore);
}; //将指定键名和键值作为参数来设置本地存储
Storage.prototype.getBestScore = function () {
return window.localStorage.getItem(BestScoreKey);
}; //返回本地存储中键名为BestScoresKey的值,即最高分
// 存储方格状态和分数
Storage.prototype.setCellState = function ({ score, grid }) {
window.localStorage.setItem(
CellStateKey,
JSON.stringify({
//转换为JSON字符串
score,
grid: grid.serialize(),
})
); //以便下一次打开游戏页面时可以恢复之前的状态
}; //将方格状态和得分序列化成 JSON 字符串并将其保存到浏览器的本地存储中
// 获取方格信息
Storage.prototype.getCellState = function () {
const cellState = window.localStorage.getItem(CellStateKey);
return cellState ? JSON.parse(cellState) : null;
};
//从本地存储中获取之前保存的方格状态和得分,并将其解析为对象后返回
//如果之前没有保存过数据,则返回 null
3.7. 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
};
};
new Manager();
版权归原作者 柳智麒 所有, 如有侵权,请联系我们删除。