相信大家都玩过 2048 吧!什么,你还没玩过??那就快来跟我一起玩一下吧,顺便了解一下如何使用 canvas
+ js
开发一款我们自己的 2048 吧!话不多说,let’s go~
首先我们还是先来玩一下 2048,并且了解一下到底 2048 的相关规则。可以看一下下面的动态图片,应该就能了解 2048 的大致玩法了,如图:
上图中,我们可以通过控制键盘的 上下左右 键来移动对于的数字,当两个数字相同时,它们就会合成更大的数字,例如 2 和 2 可以合成 4,同理越大的数字合成的数字也越大,当整个区域没有可以合成的数字后,游戏就结束了,这就是 2048 的大致规则了,下面我们就一起来看一下该如何实现一个自定义的 2048 吧!
因为我们整个游戏是基于 canvas
来编写的,但是还需要有一个基础的架子,我们使用 html
+ css
来完成,大致的 html
代码如下:
2048
Size:
StartResetScore: 0Best Score: 0> Start <Game Over!
在上面的代码中,我们通过在页面添加 input
输入框,让玩家可以自定义 2048 的游戏区域,通过输入不同的数字,可以生成不同的游戏区域,当然有个限制,最小是 3 块,最大是 10 块,并且还添加了相关的游戏数据,例如当前的游戏分数,以及当游戏结束时,我们会通过 localstorage
将最高分数记录在本地,这样当下次再玩时,如果获得的分数没有之前的分数高,则不会进行分数的更新,反之就会将最新的分数存储在本地并展示在页面上。
有了基本的 html
架子还不够,还需要添加相关的 css
代码才能让我们的游戏有一个基本的样子,这里只截取部分 css
代码,完整的代码会在最后放出,css
相关的代码如下:
...
#canvas{background: rgba(var(--blue),1);margin-top: 30px;box-sizing: border-box;
}
h1{font-size: 58px;color: white;margin-top: 30px;
}
.scores{width: 50%;display: flex;flex-direction: column;justify-content: center;align-items: flex-end;
}
.edits{min-width: 500px;width: 100%;display: flex;flex-direction: row;justify-content: space-between;align-content: center;margin-top: 30px;font-size: 24px;
}
.set-size{width: 100%;display: flex;justify-content: flex-start;align-items: center;flex-direction: row;
}
.size-block{display: flex;flex-direction: column;width: 50%;justify-content: center;align-items: flex-start;
}
.btns, #bestScore{margin-top: 15px;
}
.size-block input{margin-left: 10px;max-width: 50px;
}
#size{background: transparent;border: 2px solid white;color: white;font-size: 18px;padding: 5px 8px 5px 5px;width: 60px;outline: none;box-sizing: border-box;text-align: center;
}
.lose, .start2{z-index: 999;position: absolute;margin-top: 15px;font-size: 35px;display: none;
}
.start2{display: block;min-width: 150px;margin-top: 200px;cursor: pointer;
}
#canvas-block{position: relative;display: flex;justify-content: center;align-items: center;
}
.btns{display: flex;flex-direction: row;justify-content: flex-start;align-items: center;cursor: pointer;
}
.reset{margin-left: 15px;
}
通过 html
和 css
的加成,最终的展示如下图所示:
因为我们还没有添加相关的 js
代码,因此整个页面上除了一些静态的文字以外,别的基本都看不到了。
基础的架子已经搭建好了,那么我们就来实现这个游戏吧!
我们使用 canvas
的时候本身就是需要配合 js
来进行开发的,因为 canvas
只是一个标签元素,就跟普通的 div
元素一样,只是它上面提供了很多方法给我们使用。这里我们使用 ES6 相关的知识点来进行开发,如果对 ES6 还不熟悉的童鞋,可以点击这里进行学习。
因为使用的是 ES6 ,因此我们直接使用 面向对象 的写法来开发这个游戏。在 ES6 中,它给我们提供了一个 class 语法糖,让我们可以不需要在构造函数的原型上去添加方法,不过本质上也还是一样的,这里不做深究。
首先我们有一个 类,这个 类 的名字你可以随意定,这里就叫做 Game,然后需要在这个 类 的 构造函数 中初始化相关的内容,让我们可以在后续使用,代码如下:
class Game {constructor() {this.canvas = document.getElementById('canvas');this.ctx = this.canvas.getContext('2d');this.sizeInput = document.getElementById('size');this.startBtn = document.querySelector('.start');this.startBtn2 = document.querySelector('.start2');this.scoreLabel = document.getElementById('score');this.resetBtn = document.querySelector('.reset');this.lessHtml = document.querySelector('.lose');this.scoreValue = 0;this.bestScore = document.getElementById('bestScore');this.bestScoreValue = localStorage.getItem('score2048');this.size = 4;this.width = this.canvas.width / this.size - 6;this.cells = [];this.fontSize = 0;this.loss = false;}
}
在这个 构造函数 中,我们将页面中需要用到的元素通过 document.getElementById
或者 document.querySelector
选取到,这样后续就可以直接使用。
基本的信息有了后,我们就该初始化游戏的相关信息了,代码如下:
...
init() {this.startBtn.addEventListener('click', () => {this.publicEvent();});this.resetBtn.addEventListener('click', () => {this.scoreValue = 0;this.canvas.style.opacity = '1';this.loss = false;this.lessHtml.style.display = 'none';this.bestScoreValue = localStorage.getItem('score2048');this.scoreLabel.innerHTML = `Score: ${+this.scoreValue}`;this.startGame();this.initScore();});
}
initState() {this.initStart();this.initScore();this.initEvent();
}
initStart() {this.canvas.style.display = 'none';this.startBtn2.addEventListener('click', () => {this.publicEvent();});
}
publicEvent() {if (this.sizeInput.value >= 3 && this.sizeInput.value <= 10) {this.size = this.sizeInput.value;this.width = this.canvas.width / this.size - 6;this.canvasClear();this.startGame();this.canvas.style.display = 'block';this.startBtn2.style.display = 'none';} else {alert('不在生成的区间内,无法开始游戏');return;}
}
...
在初始化的过程中,我们需要给页面上的相关元素添加点击事件,包括开始游戏、重置游戏等内容,然后我们需要在 Game 的 构造函数,也就是上述的 constructor
中执行 init 方法,这样当整个 类 被 实例化 的时候,就会自动将各种初始化信息及绑定事件完成。
当初始信息和绑定事件都完成后,页面中的 canvas
上目前还是没有任何内容显示的,因此我们就需要通过 canvas
相关的 api
来生成对应的方块和数字了。
这里我们还需要一个新的 类,用来帮我们生成每一个小方块里面的初始值和它的位置信息,这样我们才能在 canvas
上画出来,小方块的生成代码如下:
class Cell {constructor(row, col, width) {this.value = 0;this.x = col * width + 5 * (col + 1);this.y = row * width + 5 * (row + 1);}
}
这个 类 很简单,只有三个属性 ,其中 value
默认为 0,这么做是为了后面初始化的时候根据不同的 value 生成不同颜色的方块。其次是 x 和 y,这两个值主要是这个小方块在 canvas
中的位置信息,通过从外部传入的行、列以及宽度来生成。
接下来我们只需要根据前面设置的 初始值 或者 input
中输入的值来生成对应的游戏区域即可,代码如下:
async createCells() {for (let i = 0; i < this.size; i++) {this.cells[i] = [];for (let j = 0; j < this.size; j++) {this.cells[i][j] = new Cell(i, j, this.width);}}
}
上述代码中,通过两个循环来创建小方块的位置信息,最终的数据如下图所示:
通过上图可以看到我们已经生成了一个 4 * 4 的格子,并且里面的每一个小方块的 x轴 和 y轴 信息都已经有了,接下来我们就可以通过上面的信息来生成一个基本的游戏区域了,生成的游戏区域如下图所示:
那么我们是如何生成上面这样的游戏区域的呢?让我们一起来看代码,如下:
...other code
async drawAllCells() {for (let i = 0; i < this.size; i++) {for (let j = 0; j < this.size; j++) {this.drawCell(this.cells[i][j]);}}
}
...other code
通过调用 drawAllCells 方法,在内部循环执行 drawCell 方法,然后得到上图。我们一起来看一下 drawCell 内部是如何实现的,代码如下:
drawCell(cell) {this.ctx.beginPath();this.ctx.rect(cell.x, cell.y, this.width, this.width);this.ctx.fillStyle = "#384081";this.ctx.fill();
}
通过上述代码可以看到,我们直到现在才真正使用到了 canvas
上面提供的相关方法。首先我们要调用画布上面的 beginPath 方法,这个 API 主要用于 “作画” 的开始,因此它是必须的,只要使用 canvas
绘制,就一定需要这个方法;然后我们使用了 rect
方法,它主要用于绘制一个方块,其中有四个参数,前两个参数分别是需要绘制方块的 x轴坐标 和 y轴坐标,后两个参数就是这个方块的 宽度 和 高度,这些信息在前面我们都准备好了,因此这里就可以直接拿来用;最后我们需要给方块添加颜色,也就是 fillStyle,并完成收尾,也就是填充这个方块 fill,这样我们就得到了如上图所示的游戏区域。
当我们将这些准备工作完成后,接下来就要实现当玩家点击开始后,游戏区域生成对应的小方块和数字了,那么该如何做呢?在前面我们初始化的时候,已经给相关的按钮添加了点击事件,并且绑定了一个 startGame 方法,让我们看一下这个方法内部的实现,如下:
async startGame() {await this.createCells();await this.drawAllCells();await this.pasteNewCell();await this.pasteNewCell();
}
这个方法很简单,只是调用了 createCells 和 drawAllCells 方法用于生成基本的游戏区域,然后后面连续调用了两次 pasteNewCell 方法,那 pasteNewCell 内部实现了什么呢?让我们一起来看一下代码,如下:
async pasteNewCell() {let countFree = 0;for (let i = 0; i < this.size; i++) {for (let j = 0; j < this.size; j++) {if (!this.cells[i][j].value) {countFree++;}}}if (!countFree) {this.finishGame();return;}while (true) {let row = Math.floor(Math.random() * this.size);let col = Math.floor(Math.random() * this.size);if (!this.cells[row][col].value) {this.cells[row][col].value = 2 * Math.ceil(Math.random() * 2);this.drawAllCells();return;}}
}
在 pasteNewCell 内部,我们通过不断的循环来生成新的游戏方法,其中最主要的就是判断当前小方块的值是否为 0,如果不为 0,就会画一个新的方块展示在游戏区域内,反之则不执行。
通过两次执行 pasteNewCell 方法,是为了在初始化的时候生成两个数字,这样才能开始游戏。而通过生成的不同方法内的值来生成不同颜色的小方法,这是如何实现的呢?还记得前面的 drawCell 方法吗?这里其实还有一部分代码之前没有放出来,我们一起来看看,如下:
drawCell(cell) {this.ctx.beginPath();this.ctx.rect(cell.x, cell.y, this.width, this.width);this.ctx.fillStyle = "#384081";this.ctx.fill();if (cell.value) {this.ctx.fillStyle = `${this.cellColor(cell.value)}`;this.ctx.fill();this.fontSize = this.width / 2;this.ctx.font = this.fontSize + 'px Viga';this.ctx.fillStyle = 'white';this.ctx.textAlign = "center";this.ctx.fillText(cell.value, cell.x + this.width / 2, cell.y + this.width / 1.5);}
}
cellColor(value) {const colorList = new Map([[0, 'rgb(135,200,116)'],[2, 'rgb(135,200,116)'],[4, 'rgb(95,149,212)'],[8, 'rgb(139,89,177)'],[16, 'rgb(229,195,81)'],[32, 'rgb(202,77,64)'],[64, 'rgb(108,129,112)'],[128, 'rgb(207,126,63)'],[256, 'rgb(82,125,124)'],[512, 'rgb(191,76,134)'],[1024, 'rgb(119,41,92)'],[2048, 'rgb(118,179,194)'],[4096, 'rgb(52,63,79)'],]);return colorList.get(value) || 'rgba(70,80,161,0.8)';
}
上述代码与前面的代码相比,通过判断当前的小方块的值是否为真,也就是当前小方块的值是否不为 0,从而重新生成一个新的小方块。在 drawCell 内部,通过新生成的小方块的 value 值来调用 cellColor 方法,在 cellColor 方法内,我们使用 ES6 中的 Map
方法来匹配到当前不同 value 所对应的颜色值,这样比直接使用 if…else 或 switch…case… 要更加清晰明了。
最后我们还需要添加相关的键盘绑定的事件,在最开始初始化的时候我们已经给 document
绑定了相关的键盘事件,代码如下:
initEvent() {document.addEventListener('keydown', (event) => {if (!this.loss && this.canvas.style.display === 'block') {switch (event.key) {case 'ArrowUp':this.moveUp();break;case 'ArrowDown':this.moveDown();break;case 'ArrowLeft':this.moveLeft();break;case 'ArrowRight':this.moveRight();break;}this.scoreLabel.innerHTML = `Score: ${+this.scoreValue}`;}});
}
在上述的键盘事件中,通过监听玩家按下的键盘方法键,从而让我们的小方块进行相关的合并,这里截取部分代码,如下:
...other code
moveUp() {let row;for (let j = 0; j < this.size; j++) {for (let i = 1; i < this.size; i++) {if (this.cells[i][j].value) {row = i;while (row > 1) {if (!this.cells[row - 1][j].value) {this.cells[row - 1][j].value = this.cells[row][j].value;this.cells[row][j].value = 0;row--;} else if (this.cells[row][j].value === this.cells[row - 1][j].value) {this.cells[row - 1][j].value *= 2;this.scoreValue += this.cells[row - 1][j].value;this.cells[row][j].value = 0;break;} else {break;}}}}}this.pasteNewCell();
}
moveDown() {let row;for (let j = 0; j < this.size; j++) {for (let i = this.size - 2; i >= 0; i--) {if (this.cells[i][j].value) {row = i;while (row + 1 < this.size) {if (!this.cells[row + 1][j].value) {this.cells[row + 1][j].value = this.cells[row][j].value;this.cells[row][j].value = 0;row++;} else if (this.cells[row][j].value === this.cells[row + 1][j].value) {this.cells[row + 1][j].value *= 2;this.scoreValue += this.cells[row + 1][j].value;this.cells[row][j].value = 0;break;} else {break;}}}}}this.pasteNewCell();
}...other code
在上面的代码中,通过获取当前的方块移动的方法,找到它左、右、上或者下方的方块,从而判断它们的值是否一致,如果一致就可以进行合并,反之则不能进行合并和移动。当玩家的游戏区域内已经没有任何可移动的小方块时,整个游戏就结束了,最终游戏结束的代码如下:
finishGame() {const currentScore = localStorage.getItem('score2048');if (currentScore < this.scoreValue) {localStorage.setItem('score2048', this.scoreValue);}this.canvas.style.opacity = '0.3';this.loss = true;this.lessHtml.style.display = 'block';
}
当游戏结束时,就像一开始说的一样,我们会获取当前的值以及存储在 localStorage
中的值进行对比,如果当前的值比之前的值要大,则会将新的值更新到 localStorage
中,最后在页面中展示一个 Game Over 告诉玩家游戏结束了。
最终整个游戏的实现在这里可以查看,也可以直接通过键盘玩耍,如下:
通过上面对 ES6 的基本使用以及 canvas
中基础 API 的介绍,相信大家已经学会了如何开发一个 2048 小游戏了,那么就赶快动手实现一个你自己的 2048 吧!