前言
非常经典的一个小游戏,也非常的消磨时间。而这个游戏也是我工作之余消磨时间消磨出来的。俄罗斯方块起源于拼图游戏,后来发展出了很多玩法,其中最为经典的包含了七种方块,L字型、J型、Z字型、S型、T字型、I型、O字形,游戏中需要把方块旋转,堆叠等进行消除获取分数,随着分数增加,难度也会增加。
一、基本布局
此游戏为了方便,我直接使用的是vue2,并且使用CDN方式引入,少了很多配置文件,运行也快。
关于布局,我用了一个背景图作为游戏场景图,让游戏更有氛围,其他还有旋转音效、消除音效。
(1)先看效果图:
在效果图中我们可以看到遮罩提示、分数以及下一个方块。
(2)布局
<div id="app">
<!-- 基本布局 -->
<div class="tetris">
<img src="img/bg4.jpg" alt="" class="tetris-bg">
<!-- 棋盘 -->
<div class="tetris-box">
<div class="map">
<div class="tr" v-for="(item1, index1) in list" :key="index1">
<div class="td" v-for="(item2, index2) in item1" :key="index2">
<div class="brick brick-1" v-if="item2 == 1 || item2 == 2"></div>
</div>
</div>
</div>
<!-- 游戏遮罩 -->
<div class="mask" v-if="!isStart || (isStart && isStop)">按<span>空格</span>开始游戏</div>
</div>
<!-- 下一个形状 -->
<div class="tetris-next">
<div class="tetris-next-tr" v-for="item1 in list_next">
<div class="tetris-next-td" v-for="item2 in item1">
<div class="brick brick-1" v-if="item2 == 1"></div>
</div>
</div>
</div>
<!-- 分数 -->
<div class="tetris-score">{{ score }}</div>
</div>
<!-- 背景音乐 -->
<audio ref="audioBg" autoplay loop>
<source src="audio/bg.mp3" />
</audio>
<!-- 上下左右音效 -->
<audio ref="audio1">
<source src="" />
</audio>
<!-- 消除音效 -->
<audio ref="audio2">
<source src="" />
</audio>
</div>
(3)样式
基本样式如下,
这里方块颜色,我统一用的是蓝色,也就是游戏中方块用的同一种色块,这样方便玩家观察。
但是有的俄罗斯方块是多色系的。这里可以直接去我源码的index.css上看,我提供了七种颜色砖块。
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.tetris {
width: 580px;
height: 930px;
overflow: hidden;
display: flex;
}
.tetris-box {
position: absolute;
width: 274px;
height: 355px;
top: 302px;
left: 135px;
z-index: 2;
background-color: transparent;
border-radius: 30px;
overflow: hidden;
}
.tetris-bg {
height: 100%;
}
/* 游戏区 */
.map {
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.map .tr {
flex: 1;
display: flex;
}
.map .tr .td {
flex: 1;
}
/* 遮罩 */
.mask {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 10;
background-color: rgba(0,0,0,.6);
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
letter-spacing: 4px;
user-select: none;
line-height: 1;
}
.mask span {
font-size: 25px;
font-weight: bold;
}
/* 下一个 */
.tetris-next {
width: 60px;
height: 60px;
position: absolute;
z-index: 10;
left: 484px;
top: 330px;
border-radius: 20px;
display: flex;
flex-direction: column;
filter: opacity(.6);
}
.tetris-next .tetris-next-tr {
flex: 1;
display: flex;
}
.tetris-next .tetris-next-tr .tetris-next-td {
flex: 1;
}
/* 分数 */
.tetris-score {
width: 70px;
text-align: center;
position: absolute;
left: 25px;
top: 492px;
z-index: 10;
color: #b0cfff;
filter: opacity(.5);
font-size: 30px;
font-weight: bold;
transform: rotate(14deg);
text-shadow: -4px 4px 4px rgba(0,0,0,.5);
}
/* 定义方块样式 */
.brick {
width: 100%;
height: 100%;
overflow: hidden;
border: 4px solid;
}
.brick.brick-1 {
background: linear-gradient(to bottom, rgb(145,207,249), rgb(55,117,212));
border-top-color: rgb(214,238,254);
border-right-color: rgb(27,114,231);
border-bottom-color: rgb(13,78,210);
border-left-color: rgb(144,206,248);
}
二、游戏实现
(1)定义我们需要的基本参数
list: [], // 地图数组 0=空地 1=移动砖块 2=固定砖块
list_next: [], // 存放下一个图形的地图
row: 18, // 行
col: 14, // 列
speed: 400, // 下落速度
isStart: false, // 是否开始
isOver: false, // 是否结束
isStop: true, // 是否暂停
brickList: [], // 总的形状数组
brickNow: [], // 当前形状
nowIndex1: null, // 当前是哪个图形
nowIndex2: null, // 当前图形是哪个形状
brickNext: [], // 下一个
nextIndex1: null, // 下一个是哪个图形
nextIndex2: null, // 下一个图形是哪个形状
timer: null, // 定时器
score: 0, // 分数
scoreList: [ // 消除分数数组
{ row: 1, score: 10 },
{ row: 2, score: 30 },
{ row: 3, score: 60 },
{ row: 4, score: 100 }
],
(2)定义我们需要用到的方法
比如我们需要用的效果延迟定时器(后面会说到)、音效播放。
// 延迟加载特效
sleep(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, time)
})
},
// 上下左右音效
playAudioRotate() {
if(this.$refs.audio1) {
this.$refs.audio1.src = 'audio/rotate.mp3';
this.$refs.audio1.play();
}
},
// 消除音效
playAudioClear() {
if(this.$refs.audio2) {
this.$refs.audio2.src = 'audio/clear.mp3';
this.$refs.audio2.play();
}
},
(3)监听键盘事件
我们在初始化的时候,需要开启监听键盘按下事件,监听用户按下WASD、上下左右、空格
其中涉及对应的方法,后面会说
// 监听键盘事件
window.addEventListener('keydown', (e) => {
if(e.keyCode === 32) {
this.onSpace();
}
if([37,38,39,40, 87,65,83,68].includes(e.keyCode)) {
this.onDirection(e.keyCode)
}
}, true);
(4)数据初始化
我们需要根据row、col动态生成二维地图
还要生成存放下一块图形的地图
// 生成地图
createMap() {
this.list = [];
for (let i = 0; i < this.row; i++) {
this.list[i] = [];
for(let j = 0; j < this.col; j++) {
this.list[i][j] = 0;
}
}
for (let i = 0; i < 4; i++) {
this.list_next[i] = [];
for(let j = 0; j < 4; j++) {
this.list_next[i][j] = 0;
}
}
this.initAnimate()
},
(5)游戏初始动画以及游戏结束时的动画(initAnimate)
在上面,我们可以看到有一个this.initAnimate()方法,
这个方法就是经典俄罗斯方块中的至下而上清屏效果
也就是会从最底一行开始往上运动,到达第一行再运动下去,一整个效果。
可直接复制完整代码查看效果
// 初始化地图动画
// 因为sleep方法是用promise处理的,所以这里我们需要用async和await
async initAnimate() {
// 画面整体从下到上扫一遍
for (let i = this.row - 1; i >= 0; i--) {
await this.sleep(30)
for(let j = 0; j < this.col; j++) {
this.list[i][j] = 1;
}
this.$forceUpdate()
}
// 画面整体从上到下扫一遍
for (let i = 0; i < this.row; i++) {
await this.sleep(30)
for(let j = 0; j < this.col; j++) {
this.list[i][j] = 0;
}
this.$forceUpdate()
}
this.isStart = false;
this.isOver = false;
},
我们为什么要用sleep方法呢,我当时做这个的时候,直接用的两个for循环,但是效果就是闪一下。没有那种感觉,这时候问题就是,如何让第一行先出现,然后经过某个时间再让第二行也出现?以此类推。
这里我就想到了面试常见的一个题目:
给一个数组arr = [ 1,2,3,4,5 ],如何一秒钟输出一个数字
也就是第一秒输出1,经过一秒输出2…
let arr = [1,2,3,4,5]
for(let i = 0; i < arr.length; i++) {
await this.sleep(1000)
console.log(arr[i]);
}
显然,我们这里也需要用到这个触发机制,效果就会出来。
(6)生成所需要的方块图形(createBrick)
上面说了,一共七种,并且包含每种旋转后的图形。
createBrick() {
this.brickList = [
[
[[0,1], [1,1], [2,1], [2,2]],
[[1,2], [1,1], [1,0], [2,0]],
[[2,1], [1,1], [0,1], [0,0]],
[[1,0], [1,1], [1,2], [0,2]],
], // L
[
[[0,1], [1,1], [2,1], [2,0]],
[[1,2], [1,1], [1,0], [0,0]],
[[2,1], [1,1], [0,1], [0,2]],
[[1,0], [1,1], [1,2], [2,2]],
], // J
[
[[0,0], [0,1], [1,1], [1,2]],
[[0,2], [1,2], [1,1], [2,1]],
], // Z
[
[[0,2], [0,1], [1,1], [1,0]],
[[2,2], [1,2], [1,1], [0,1]],
], // S
[
[[0,1], [1,0], [1,1], [1,2]],
[[1,2], [0,1], [1,1], [2,1]],
[[2,1], [1,2], [1,1], [1,0]],
[[1,0], [2,1], [1,1], [0,1]],
], // T
[
[[0,1], [1,1], [2,1], [3,1]],
[[1,0], [1,1], [1,2], [1,3]],
], // |
[
[[0,1], [0,2], [1,1], [1,2]],
] // O
];
// 生成完方块,需要随机抽取一个作为第一个图形
this.randomBrick();
},
(7)随机产生一个形状(randomBrick)
我只拿到某个图形的第一个形态
我们还要记录一下当前拿到的放块是哪个图形的哪个形态,为了后面做旋转所用
randomBrick() {
let randomNum = Math.floor(Math.random() * this.brickList.length);
this.brickNext = this.brickList[randomNum][0]; // 默认拿出某个图形的第一个形态
this.nextIndex1 = randomNum;
this.nextIndex2 = 0;
// 每次随机拿一个方块,就要把之前的方块清空掉,避免渲染重复
for (let i = 0; i < 4; i++) {
for(let j = 0; j < 4; j++) {
this.list_next[i][j] = 0;
}
}
// 把下一个渲染在next数组上面
for (let i = 0; i < this.brickNext.length; i++) {
let [x, y] = this.brickNext[i];
this.list_next[x][y] = 1;
}
this.$forceUpdate()
},
(8)空格 —— 游戏开始(onSpace、onPassOn、drawdBrick)
处理空格事件:
空格的作用就是开始游戏、暂停、继续
首先开始游戏,我们需要把下一个方块传递过来,作为当前下落方块使用(onPassOn方法)
其次开始我们要开始方块下落定时器 最后暂停的时候我们需要清楚定时器
onSpace() {
if(this.isOver) return;
if(!this.isStart) {
this.isStart = true;
this.onPassOn()
}
if(this.isStop) {
this.isStop = false;
this.dropBrick()
} else {
this.isStop = true;
clearInterval(this.timer)
}
},
关于onPassOn方法
把下一个形状传递给当前
传递的时候需要注意,我们默认的图形是在边缘创建的,我们传递给当前的话,需要把图形放到中间去
传递完,自己要还要重新生成一个新的图案
onPassOn() {
let arr = JSON.parse(JSON.stringify(this.brickNext));
let a = Math.floor(this.col / 2 - 2);
arr.forEach(item => {
item[1] += a
})
this.brickNow = arr;
// 记录当前是哪个图形的哪个形状
this.nowIndex1 = JSON.parse(JSON.stringify(this.nextIndex1));
this.nowIndex2 = JSON.parse(JSON.stringify(this.nextIndex2));
this.drawdBrick()
this.randomBrick()
},
关于 drawdBrick 方法
把当前图形渲染在地图上
每次渲染前,要把除了固定的砖块(=2)外的都清除一下
每次渲染完成,我们都要去判断游戏是否结束了
drawdBrick() {
for (let i = 0; i < this.list.length; i++) {
for(let j = 0; j < this.list[i].length; j++) {
if(this.list[i][j] !== 2) {
this.list[i][j] = 0;
}
}
}
for (let i = 0; i < this.brickNow.length; i++) {
let [x, y] = this.brickNow[i];
this.list[x][y] = 1;
}
this.$forceUpdate();
this.isEnd();
}
关于isEnd方法
判断游戏是否结束
我们只要判断地图中第一行是否有砖块 = 2就行
isEnd() {
let bool = this.list[0].some(item => item == 2);
if(bool) {
clearInterval(this.timer);
this.isOver = true;
this.isStart = false;
this.isStop = true;
this.brickNow = [];
this.score = 0;
this.initAnimate()
}
}
(9)方块下落(dropBrick)
利用定时器来让图形每隔一定时间就会往下滑动一格
方法就是让图形的每个砖块row都+1即可
dropBrick() {
this.timer = setInterval(() => {
// 如果到底了,则需要把方块固定, 然后进行下一个
if(this.isToBottom()) {
let fixedArr = JSON.parse(JSON.stringify(this.brickNow))
for (let i = 0; i < fixedArr.length; i++) {
let [x, y] = fixedArr[i];
this.list[x][y] = 2;
}
this.onClean();
return;
}
// 每个砖块的row都加1 = 下落
for (let i = 0; i < this.brickNow.length; i++) {
this.brickNow[i][0] += 1;
}
this.drawdBrick()
}, this.speed)
}
判断是否到底,或者下面已有砖块
// 判断是否触底 true到底或者下面已有砖块
isToBottom(arr = this.brickNow){
let bool = false;
arr.forEach(item=>{
if(item[0] == this.row - 1 || this.list[item[0] + 1][item[1]] == 2){
bool = true;
}
})
return bool;
},
(10)消除行(onClean)
到底了,或者下面有砖块了,这时候也就会固定住当前砖块,固定完,我们还需要知道当前是否有可以消除的情况
this.onClean()方法就是用于消除行的
消除行的时候,我们回想一下经典俄罗斯方块会有个闪动的效果,这里也需要用到之前说的sleep方法
首先我们需要知道有哪些行可以消除,也就是找出一行中全部 = 2的一行
其次我们让这些行进行闪动效果,闪动完整行方块全部设为 = 0
最后消除的行上面的已有固定的砖块要全体进行下移。
async onClean() {
let arr = JSON.parse(JSON.stringify(this.list));
let clearList = []; // 可以消除的行数
for(let i = arr.length - 1; i >= 0; i--) {
let a = arr[i].every(item => item == 2);
if(a) {
clearList.push(i); // 我们只要拿到行数就可以了
}
}
if(clearList.length > 0) {
// 播放消除音效
this.playAudioClear()
// 这里要做闪动的效果,用了定时器处理,效果开始之前不能让方块往下滑动,要暂停
clearInterval(this.timer);
// 这里我做了闪动5次,也就是让 0 和 1之间来回切换,最后以0结尾
for(let z = 0; z < 5; z++) {
await this.sleep(100)
for(let i = 0; i < clearList.length; i++) {
let a = clearList[i];
for(let j = 0; j < arr[a].length; j++) {
this.list[a][j] = (z % 2 == 0 ? 0 : 1);
}
}
this.$forceUpdate();
}
// 消除行之后 剩余的要下移
// 我们要从上往下进行一行一行处理
for(let i = clearList.length; i >= 0; i--) {
for(let j = clearList[i] - 1; j >= 0; j--) {
for(let k = 0; k < this.col; k++) {
if(this.list[j][k] == 2) {
// 把固定方块清空,它下面那一块设为固定=2
this.list[j][k] = 0;
this.list[j + 1][k] = 2;
}
}
}
}
this.$forceUpdate();
// 消除完要计算分数
this.onCountScore(clearList.length);
// 消除完重新开启方块下落
this.dropBrick()
}
// 无论有没有消行,都要把下一个图形拿过来用
this.onPassOn();
},
(11)左移动、右移动、下移动、上旋转(onDirection)
这里需要说明,我们进行左右下移动的时候,一定需要判断临界值,也就是不能超出地图
并且还需要判断左右下是否有砖块
左右有砖块 怎不能移动
下有砖块则固定到砖块上面
// 上下左右
onDirection(code) {
if(!this.isStart || this.isOver) return;
// 音效
this.playAudioRotate()
// 上 旋转 (具体在下一个)
if(code == 38 || code == 87) {
this.onRotate()
}
// 下 加快下落
// 找出图案最下面,首先触碰的砖块
// 根据当前图形,一层一层往下找,是否有砖块或者是否到底
if((code == 40 || code == 83) && !this.isToBottom()) {
let brickCopy = JSON.parse(JSON.stringify(this.brickNow));
for(let i = 0; i < this.row; i++) {
for(let j = 0; j < brickCopy.length; j++) {
brickCopy[j][0] += 1;
}
if(this.isToBottom(brickCopy)) {
this.brickNow = JSON.parse(JSON.stringify(brickCopy));
break;
}
}
}
// 左 左移动
if((code == 37 || code == 65) && !this.isToLeft()) {
for (let i = 0; i < this.brickNow.length; i++) {
this.brickNow[i][1] -= 1;
}
}
// 右 右移动
if((code == 39 || code == 68) && !this.isToRight()) {
for (let i = 0; i < this.brickNow.length; i++) {
this.brickNow[i][1] += 1;
}
}
this.drawdBrick()
},
如上代码中,判断左右临界值方法isToLeft、isToRight 具体如下
// 判断左边是否到边,或者是否有砖块
isToLeft() {
let bool = false;
this.brickNow.forEach(item=>{
let [x, y] = item;
if(y == 0 || this.list[x][y - 1] == 2){
bool = true;
}
})
return bool;
},
// 判断右边是否有边,或者是否有砖块
isToRight() {
let bool = false;
this.brickNow.forEach(item=>{
let [x, y] = item;
if(y == this.col - 1 || this.list[x][y + 1] == 2){
bool = true;
}
})
return bool;
},
(12)按上 —— 方块旋转(onRotate)
旋转操作其实比较简单
- 我们只要计算 原始图形的位置与当前图形位置中每个砖块的row col的差值
- 再去获取旋转后的原始图形
- 再把旋转后的原始图形的row col都加上差值
- 会得到当前旋转后的图形及位置
- 具体细节可看代码中注释
onRotate() {
// 获取当前图形的变化数组
let arr = JSON.parse(JSON.stringify(this.brickList[this.nowIndex1]));
// 如果大于1 说明有旋转后的图形 比如O型就只有一种,则不能旋转
if(arr.length > 1) {
// 拿到原始图形,也就是刚开始创建的7个原始图形
let protoBrick = JSON.parse(JSON.stringify(this.brickList[this.nowIndex1][this.nowIndex2]));
// 复制一份当前图形
let brickNowCopy = JSON.parse(JSON.stringify(this.brickNow));
// 差值列表
let gapArr = [];
// 计算出当前图形与原始图形的差值,也就是移动了多少步了
for(let a = 0; a < brickNowCopy.length; a++) {
gapArr[a] = [
brickNowCopy[a][0] - protoBrick[a][0],
brickNowCopy[a][1] - protoBrick[a][1]
];
}
// 这里我们需要拿到旋转后的图形,所以之前需要记录当前图形是哪个图形的哪个形状
// 如果转到最后一个,则需要重新回到第一个
this.nowIndex2 += 1;
if(this.nowIndex2 == arr.length) {
this.nowIndex2 = 0;
}
// 因为整个图形的每个方块是一起移动的,所以我们只要拿到第一个差值数据即可
let [xGap, yGap] = gapArr[0];
// 拿到原始图形旋转后的图形
let protoBrickNextCopy = JSON.parse(JSON.stringify(this.brickList[this.nowIndex1][this.nowIndex2]));
// 把原始图形旋转后的图形都加上差值,计算出旋转后的图形目前在哪个位置
protoBrickNextCopy.forEach(item => {
item[0] += xGap;
item[1] += yGap;
})
// 得到旋转后的图形之后,我们需要判断旋转后的图形的数组中,是否有负数或者大于地图的边界值(也就是出地图了),
// 或者坐标映射在地图中是否有等于2的情况(也就是跟固定砖块重叠了),这两种情况都不能旋转
let bool = false;
for(let a = 0; a < protoBrickNextCopy.length; a++) {
let [x, y] = protoBrickNextCopy[a];
if(x < 0 || y < 0 || x > this.row - 1 || y > this.col - 1 || this.list[x][y] == 2) {
bool = true;
break;
}
}
// 因为上面我们把nowIndex+1了,则如果不能旋转,则需要同时要把当前图形的下标在变成之前的那个图形
if(bool) {
this.nowIndex2 -= 1;
if(this.nowIndex2 == -1) {
this.nowIndex2 = arr.length - 1;
}
return;
}
// 这就是把旋转后的图形坐标赋值给当前的图形,然后重新渲染出来,则完成旋转
this.brickNow = protoBrickNextCopy;
this.drawdBrick()
}
},
注意上面所说的,某些不能旋转的情况,比如下面这种,旋转之后的图形,有一块就会超出地图。
还有如果方块与方块之间的空隙太小,也不能旋转。
(13)计算分数、提升难度
onCountScore(num) {
const arr = this.scoreList.filter(item => item.row == num)[0];
this.score += arr.score;
// 下落速度加快
if(this.score >= 200 && this.score < 500) {
this.speed = 300;
} else if(this.score >= 500 && this.score < 800) {
this.speed = 200;
} else if(this.score >= 800 && this.score < 1000) {
this.speed = 100;
} else if(this.score >= 1000) {
this.speed = 50;
}
}
三、总结
整体做下来,难度还算可以,其中一些小细节还有比较难啃的,但是慢慢琢磨,慢慢分析,慢慢尝试,总会有办法解决的,享受游戏的乐趣,享受学历的乐趣。
最后附上代码地址:
张大炮 —— 俄罗斯方块