最近刷抖音看到 渡一Web前端学习频道 这个账号发的 JS 使用位运算实现俄罗斯方块挺有意思的,于是决定试一试。
共实现了下列几项的功能:
- 方块布局
- 方块移动
- 方块消除
- 方块左旋
思路
表示方块
id为 obje 表格表示方块在此地图上移动,id为 table 的表格表示地图
<!-- 绝对定位位于上方 方块在此移动 -->
<table border id="obje">
tr*12>td*6
</table>
<!-- 地图,方块移动到指定条件,被添加到地图上 -->
<table border id="table">
tr*12>td*6
</table>
数据:一维数组,数组每一项都是一个数字,数据渲染到页面时,数字先转化为6位二进制数再渲染。
// 表示十二行,每一个数据转化为6位二进制,表示6列
let map = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let objee = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// map 是 table 对应的数据,objee 是 obje 对应的数据
方块移动
左右移动:对数组每一项数据进行位运算。
左移: n << 1 // 4 << 1 => 8
右移: n >> 1 // 4 >> 1 => 2
上下移动:对数组进行添加截取
// [1,2,3,4] => [0,1,2,3]
[0].concat(Array.slice(0, -1))
方块存放到地图上
当方块到达一定条件时,将原本在 obje 的方块,移动到 table 上
// 0b000100 | 0b000011 => 0b000111
objee[index] | item
有个问题,就是如果 obje 和 table 上的方块重叠了怎么办?所以在运算前要先进行一步是否重合判断。
// 判断下一步是否会有和 table 重合的方块。
const result = map.every((item, index) => {
return (item & [0].concat(objee.slice(0, -1))[index]) === 0
})
// 另一个限制是方块到达最下方也要停下。
if(!result || objee[objee.length - 1] !== 0) {
// TODO...
}
方块消除
因为方块是使用二进制表示的,所以一排如果全部为红色即值为 63,则可消除当前行。
map = map.reduce((mapClone, item, index) => {
if((objee[index] | item) === 63){
mapClone.unshift(0)
}
else {
mapClone.push(objee[index] | item)
}
return mapClone
},[])
左旋转
- 求出方块所在的九宫格最左上角的坐标。
// 最左上角坐标的 x,y
let minX = 100
let minY = 100
let temp = 0
objArr.map((item, i) => {
item.forEach((n, j) => {
if(n === '1' && cell>j) {
minY=j
}
if(n === '1' && row>i) {
minX=i
}
})
})
- 运算出旋转后的位置
temp = y
y = y + 1 - minX + minY - 1
x = minX + 3 + minY - 1 - temp
实现
布局
游戏布局使用的是 table 表格,通过改变表格背景颜色实现方块的不同显示。
分为两层显示,上层 obje 显示的是新方块的移动。下层 table 是底部地图。
HTML
<!-- 方块移动布局 -->
<table border id="obje">
tr*12>td*6
</table>
<!-- 地图 -->
<table border id="table">
tr*12>td*6
</table>
<button id="left">左移</button>
<button id="right">右移</button>
<button id="rotate-left">左旋转</button>
<button id="rotate-right">右旋转</button>
<button id="p">暂停</button>
CSS
* {
margin: auto 0;
padding: 0;
}
td {
width: 30px;
height: 30px;
}
button {
width: 50px;
height: 35px
}
/* 被选中的方块会 添加该类名 */
.select-color {
opacity: 1 !important;
background-color: red;
}
/* 使用定位将页面分为上下两个图层 */
#obje {
position: absolute;
}
/* 初始上层为透明 */
#obje td {
opacity: 0;
}
数据渲染
将数组数据转化为二进制 0 为白色,1 为红色
// 将数组的数据渲染到表格上 (数据数组,表格对象)
function renderTable(arr, table) {
arr.forEach((r, i) => {
parseInt(r).toString(2).padStart(6,'0').split('').forEach((c, j) => {
table.rows[i].cells[j].classList.remove('select-color')
if(c === '1') {
table.rows[i].cells[j].classList.add('select-color')
}
})
})
}
// 获取视图对象
const table = document.getElementById('table')
// 地图数组 每一项都代表一行
let map = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// 将数组数据渲染到表格
renderTable(map, table)
const obje = document.getElementById('obje')
// 移动地图数组 每一项都代表一行
let objee = [8, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
renderTable(objee, obje)
生成新方块
// 生成新的方块
function createSquare() {
let square = [
[8, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[8, 12, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[28, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[8, 24, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[12, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[4, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]
const i = parseInt(Math.random() * 7)
return square[i]
}
自动下降
// 每隔 1 秒下降一格
let timeout = setInterval(() => {
// map 与 obje 下一步有无重叠方块 无为 true 有为 false
const result = map.every((item, index) => {
return (item & [0].concat(objee.slice(0, -1))[index]) === 0
})
// 判断是否到达最下方边界
if(!result || objee[objee.length - 1] !== 0) {
// 将这个方块放到 map 地图里面
map = map.reduce((mapClone, item, index) => {
if((objee[index] | item) === 63){
mapClone.unshift(0)
}
else {
mapClone.push(objee[index] | item)
}
return mapClone
},[])
// 方块到达底部,生成新的方块
objee = createSquare()
renderTable(map, table)
renderTable(objee, obje)
return
}
// objee.unshift(0) objee.pop(0)
objee = [0].concat(objee.slice(0, -1))
renderTable(objee, obje)
}, 1000)
左右移动
// 向左按钮
let leftBtn = document.getElementById('left')
leftBtn.addEventListener('click', () => {
moveLeft(objee, obje, 32)
}, false)
// 向右按钮
let rightBtn = document.getElementById('right')
rightBtn.addEventListener('click', () => {
moveRight(objee, obje, 1)
}, false)
// 向左移动
function moveLeft(arr, table, i) {
// 判断
if(arr.every(item => (item & i) == 0 ? true : false)) {
if(map.every((item, index) => {
return (item & [0].concat(objee.slice(0, -1))[index]) === 0
})) {
objee = arr.map((n) => {
return i === 32 ? n << 1: n >> 1
})
}
renderTable(objee, table)
}
}
停止下移
// 暂停按钮
let pBtn = document.getElementById('p')
pBtn.addEventListener('click', () => {
clearInterval(timeout)
}, false)
左旋转
到了旋转这里,才发现用两层地图有多难受了,功能虽然实现了,但是感觉太繁琐了。
let rotateLeft = document.getElementById('rotate-left')
rotateLeft.addEventListener('click', () => {
// 将一维数组,转化为二维数组(二进制)
objArr = objee.map(item => {
return parseInt(item).toString(2).padStart(6,'0').split('')
})
// 求方块所在九宫格最左上的坐标
let row = 100
let cell = 100
let a = []
let temp = 0
objArr.map((item, i) => {
item.forEach((n, j) => {
if(n === '1' && cell>j) {
cell=j
}
if(n === '1' && row>i) {
row=i
}
})
})
// 计算出值是 1 的坐标
const b = objArr.map((item, i) => {
return item.map((n, j) => {
if(n === '1') {
a.push([i,j])
}
return 0
})
})
// 运算旋转后的坐标
a.map( item => {
temp = item[1]
item[1] = item[0] + 1 - row + cell - 1
item[0] = row + 3 + cell - 1 - temp
})
// 旋转
a.map( item => {
b[item[0]][item[1]] = '1'
})
// 二维数组再转回一维数组
let c = b.map( item => {
return parseInt(parseInt(item.join('')),2)
})
// 这里我只判断了不能和原地图重叠,还需要判断地图边界
// 旋转后不能与原来的坐标重合
if(map.every((item, index) => {
return (item & c[index]) === 0
})) {
objee = c
}
renderTable(objee, obje)
}, false)
莫喷莫喷