HTML5游戏之Websocket俄罗斯方块进阶版(二)
本篇使我们整个俄罗斯方块系列的第二门课,之前给大家讲过一门基础篇《HTML5 游戏之 Websocket 俄罗斯方块基础版(一)》,在基础篇主要是带领大家人事 Websocket 的基础知识,以及如何用它来实现一个简单的聊天功能。本篇主要是带领大家完成单机版的俄罗斯方块小游戏,包括页面的搭建、渲染、代码结构的调整、各种形式的转换、细节的丰富、以及干扰功能的实现,干货满满,希望对你有帮助,如果你对 HTML5 游戏开发感兴趣,这个对你还是有很大参考意义的。本文暂时不会涉及到有关 Websocket 通讯的知识,我们实现这个单机版小游戏也是为了我们实现终极版做一个铺垫,俄罗斯方块这个游戏,我相信大家都玩过,但是不知道大家有没有思考过它背后实现的原理,如下图就是这个游戏实现的原理,左边这个游戏区域在程序中对应的是一个二维数组,然后我们所有逻辑操作都是操作二维数组,去改变它里面的一些数据,然后我们会有一个单独的模块负责把这个二维数组渲染成我们这个游戏区域,这样做的好处也是很明显的,我们左边这个游戏区域其实就可以把它看成是一个视图 View,中间的二维数组可以把它看成是一个模型 Model,右边的逻辑操作部分我们可以把它看成是一个控制器 Controller,这样就又回到了我们的 MVC 模式,说了这么多接下来我们就用代码体验一下,一起来折腾吧。 
界面搭建
HTML 代码部分:
<body>
<div class="game" id="game"></div>
<div class="next" id="next"></div>
<div class="info">
<div>已用时:<span id="time">0</span>s</div>
<div>已得分:<span id="score">0</span>分</div>
</div>
</body>
CSS 代码部分:
.game{
width: 200px;
height: 400px;
background-color:#F2FAFF;
border-left: 1px solid blue;
border-right: 1px solid blue;
border-bottom: 1px solid blue;
position: absolute;
top:10px;
left: 10px;
}
.next{
width: 80px;
height: 80px;
background-color: #F2FAFF;
position: absolute;
top:10px;
left: 250px;
border: 1px solid blue;
}
.info{
position: absolute;
top: 100px;
left: 250px;
}
.none,.current,.done{
width: 20px;
height: 20px;
position: absolute;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.none{
background-color: #F2FAFF;
}
.current{
background-color: pink;
border: 1px solid red;
}
.done{
background-color: gray;
border: 1px solid black;
}
界面渲染逻辑
JS 代码部分,首先我们来定义一下二维数组 nextData,这个二维数组是一个 4*4 的,我们先给它填一些数据,这些数据初始化为零。
var nextData = [
[0,0,0,0],
[0,0,0,0],
[0,0,0,0],
[0,0,0,0]
]
然后我们在定义一个 10 列二次函数数组gameData,填充数据,初始化也为 0。
var gameData = [
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
]
接下来还要定一两个变量nextDivs、gameDivs,这两个变量也是二维数组,主要是用来保存 div 的。
var nextDivs = []; var gameDivs = [];
定义一个函数initGame,这个主要是用来创建div并保存在gameDivs上,先用for循环gameData,在循环中在定义一个临时的数组,再次对二维数组进行循环遍历,创建 div,并设置相关样式,然后把它加到game的div里面,然后把newNode放到gameDiv数组里面,最后把一位数组放到二维数组里面去。
var initGame = function () {
for (var i = 0; i<gameData.length; i++) {
var gameDiv = [];
for (var j = 0; j < gameData[0].length; j++) {
var newNode = document.createElement('div');
newNode.className = 'none';
newNode.style.top = (i*20) + 'px';
newNode.style.left = (j*20) + 'px';
document.getElementById('game').appendChild(newNode);
gameDiv.push(newNode);
}
gameDivs.push(gameDiv);
}
}
initNext 方法和 initGame 方法逻辑是一样的。
var initNext = function () {
for (var i = 0; i<nextData.length; i++) {
var nextDiv = [];
for (var j = 0; j < nextData[0].length; j++) {
var newNode = document.createElement('div');
newNode.className = 'none';
newNode.style.top = (i*20) + 'px';
newNode.style.left = (j*20) + 'px';
document.getElementById('next').appendChild(newNode);
nextDiv.push(newNode);
}
nextDivs.push(nextDiv);
}
}
refreshGame 函数方法,refreshGame 就是根据上面 gameData 数据然后去改变 gameDivs,逻辑很简单,我们还是要去遍历一下二维数组,然后根据 gameData[i][j]分别判断他们的不同状态。
var refreshGame = function () {
for (var i = 0; i < gameData.length; i++){
for (var j = 0; j < gameData[0].length; j++) {
if(gameData[i][j] == 0){
gameDivs[i][j].className = 'none';
}else if (gameData[i][j] == 1) {
gameDivs[i][j].className = 'done';
}else if (gameData[i][j] == 2) {
gameDivs[i][j].className = 'current';
}
}
}
}
同样的道理,refreshNext 方法和 refreshGame 方法逻辑一样
var refreshNext = function () {
for (var i = 0; i < nextData.length; i++){
for (var j = 0; j < nextData[0].length; j++) {
if(nextData[i][j] == 0){
nextDivs[i][j].className = 'none';
}else if (nextData[i][j] == 1) {
nextDivs[i][j].className = 'done';
}else if (nextData[i][j] == 2) {
nextDivs[i][j].className = 'current';
}
}
}
}
最后分别调用方法上面的方法:
initGame(); initNext(); refreshGame(); refreshNext();
为了看一下此时的效果,我们改一下nextData和gameData的数据:
var nextData = [
[2,2,0,0],
[0,2,2,0],
[0,0,0,0],
[0,0,0,0]
];
var gameData = [
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,2,1,0,0,0],
[0,0,0,2,2,2,1,0,0,0],
[0,0,1,1,1,1,1,0,0,0]
];
效果展示:
相信说到这儿大家已经知道了,一会儿我们就是要操作gameData里面的数据,最后调用refreshGame将数据反映到页面上。
代码结构调整
接下来我们对代码结构做一些调整,这个游戏逻辑是比较复杂的,如果欧我们呢把所有的代码都写到script.js里面呢,这个势必会变得比较臃肿,不好维护,所以我们要用一点模块化的思想。下面这个图就是整个案例的结构图,顶层script.js调用local.js和remote.js这两个模块,local.js代表我的游戏区域逻辑,remote.js代表对方游戏区域的逻辑,这两个模块都调用game.js模块,game.js代表游戏的核心,game.js又去调用square.js模块,square.js就代表一个方块,它又引申到 aruare.js1 到 square.js7 这几个模块,分别代表俄罗斯方块中的七种方块,这七个方块的公共逻辑比如左移、右移、旋转、下落这些逻辑会被放在square.js中,各自的逻辑又被放在各自的文件中,旁边还有一个squareFactory.js工厂类,主要用来负责生成方块。
game.js 代码:
var Game = function () {
//dome 元素
var gameDiv;
var nextDiv;
//游戏矩阵
var gameData = [
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0]
];
//当前方块
var cur;
//下一个方块
var next;
//divs
var nextDivs = [];
var gameDivs = [];
//初始化 div
var initDiv = function (container, data, divs) {
for (var i=0; i<data.length; i++) {
var div = [];
for (var j=0; j<data[0].length; j++) {
var newNode = document.createElement('div');
newNode.className = 'none';
newNode.style.top = (i*20) + 'px';
newNode.style.left = (j*20) + 'px';
container.appendChild(newNode);
div.push(newNode);
}
divs.push(div);
}
}
//刷新 div
var refreshDiv = function (data, divs) {
for (var i=0; i< data.length; i++){
for (var j=0; j<data[0].length; j++) {
if(data[i][j] == 0){
divs[i][j].className = 'none';
}else if (data[i][j] == 1) {
divs[i][j].className = 'done';
}else if (data[i][j] == 2) {
divs[i][j].className = 'current';
}
}
}
}
//初始化
var init = function (doms) {
gameDiv = doms.gameDiv;
nextDiv = doms.nextDiv;
cur = new Square();
next = new Square();
initDiv(gameDiv, gameData, gameDivs);
initDiv(nextDiv, next.data, nextDivs);
cur.origin.x = 10;
cur.origin.y = 5;
for(var i=0; i<cur.data.length; i++){
for(var j=0; j<cur.data[0].length; j++) {
gameData[cur.origin.x + i][cur.origin.y + j] = cur.data[i][j];
}
}
refreshDiv(gameData, gameDivs);
refreshDiv(next.data, nextDivs);
}
//导出 API
this.init = init;
}
sauare.js 代码:
var Square = function () {
//方块数据
this.data = [
[0,2,0,0],
[0,2,0,0],
[0,2,0,0],
[0,2,0,0]
];
//原点
this.origin = {
x: 0,
y: 0
}
}
local.js 代码:
var Local = function () {
//游戏对象
var game;
//开始
var start = function () {
var doms = {
gameDiv: document.getElementById('game'),
nextDiv: document.getElementById('next')
}
game = new Game();
game.init(doms);
}
//导出 API
this.start = start;
}
script.js 代码:
var local = new Local(); local.start();
效果展示: 
键盘控制方块下移
接下来我们实现一个这样的功能,当我们按下键盘向下方向的按键时,让我们的方块向下移动,实现这个功能呢首先需要我们在local.js增加一个这样的函数,这个函数是用来绑定键盘事件的
var bindKeyEvent = function () {
document.onkeydown = function (e) {
if (e.keyCode == 38) {
//up
} else if (e.keyCode == 39) {
//right
} else if (e.keyCode == 40) {
//down
game.down();
} else if (e.keyCode == 37) {
//left
} else if (e.keyCode == 32) {
//space
}
}
}
然后在 start 开始函数下调用:
var start = function () {
var doms = {
gameDiv: document.getElementById('game'),
nextDiv: document.getElementById('next')
}
game = new Game();
game.init(doms);
bindKeyEvent();
}
game.js 代码中,我们之前在初始化init函数中有这样一段代码:
因为这段代码非常的常用,它是用来设置数据的,所以我们对它进行一个单独的封装,然后在 init 函数中调用。
//设置数据
var setData = function () {
for(var i=0; i<cur.data.length; i++){
for(var j=0; j<cur.data[0].length; j++) {
gameData[cur.origin.x + i][cur.origin.y + j] = cur.data[i][j];
}
}
}
如何实现下移方法呢?很简单,我们将cur.origin.x加 1,即cur.origin.x=cur.origin.x+1;相当于把原点下移一位,然后我们调用setData方法,接着refreshDiv()一下数据,代码如下:
//下移
var down = function () {
cur.origin.x = cur.origin.x +1;
setData();
refreshDiv(gameData, gameDivs);
}
效果展示:
通过上图我们看到虽然实现了下移,但是之前的数据未被清理掉,所以整个效果就是变长了,接着我们再来写一个函数,把之前的数据清理一下,原理正好和设置数据相反,代码如下,然后在下移方法中先调用该清理数据clearData方法。
//清除数据
var clearData = function () {
for(var i=0; i<cur.data.length; i++){
for(var j=0; j<cur.data[0].length; j++) {
gameData[cur.origin.x + i][cur.origin.y + j] = 0;
}
}
}
//设置数据
var setData = function () {
for(var i=0; i<cur.data.length; i++){
for(var j=0; j<cur.data[0].length; j++) {
gameData[cur.origin.x + i][cur.origin.y + j] = cur.data[i][j];
}
}
}
//下移
var down = function () {
clearData()
cur.origin.x = cur.origin.x +1;
setData();
refreshDiv(gameData, gameDivs);
}
此时效果如下:
然后我们打开控制台看一下当我们一直按住下移,到最底部后,控制台会报错
这是什么原因呢?我们看一下 76 行,原因很明显,cur.origin.x已经下移到底方了,再下移cur.origin.x+i的数据就超出了gameData数据的边界溢出了,所以需要我们在设置数据函数前做一个判断,判断一下这个下移的点是不是一个合法的点,所以我单独写一个检测函数检测点是否合法。 检测函数check有这么几个参数,pos即位置也就是方块的原点cur.origin.x,x 和 y 就是clearData函数中二维数组的索引对应到setData就是 i 和 j。
//检测点是否合法
var check = function (pos, x, y) {
if (pos.x + x < 0) {
//点在上面,超出上边界
return false;
} else if (pos.x + x <= gameData.length) {
//超出了下边界
return false;
} else if (pos.y + y < 0) {
//超出了左边界
return false;
} else if (pos.y + y <= gameData.length) {
//超出了右边界
return false;
} else if (gameData[pos.x + x][pos.y + y] == 1) {
//如果这个位置已经有落下的方块
return false;
} else {
//其他都合法
return true;
}
}
然后分别在clearData函数和setData函数中进行判断它们状态。
//清除数据
var clearData = function () {
for(var i=0; i<cur.data.length; i++){
for(var j=0; j<cur.data[0].length; j++) {
if(check(cur.origin, i, j)){
gameData[cur.origin.x + i][cur.origin.y + j] = 0;
}
}
}
}
//设置数据
var setData = function () {
for(var i=0; i<cur.data.length; i++){
for(var j=0; j<cur.data[0].length; j++) {
if (check(cur.origin, i, j)){
gameData[cur.origin.x + i][cur.origin.y + j] = cur.data[i][j];
}
}
}
}
此时效果如下:
由上图可以看出,方块下移到底部控制台不会报错了,但是新的问题又来了,一直下移导致方块没有了,因为到底部后就不能下降,所以我们在下移方法中判断一下是否可以下降。这个函数我们写在 square.js 里面,我们认为它是一个方块的方法,我们把它写在一个原型链上,叫做canDown,怎么判断它呢?我们可以根据它下降之后即x+1,再判断data数据是否合法,所以我们先在 game.js 中新写一个检测数据是否合法函数方法。
检测数据是否合法 isValid 方法有这么几个参数,一个是 pos,一个是 data,这个 pos 就是 square.js 里面的 origin 原点,data 对应的就是方块数据,然后利用二层循环进行检测。
如何判断呢?我们可以判断方块数据的某个点不为 0 的话,说明这个地方是有方块存在的,其实我们也就可以去判断有方块存在的点是否可行就可以了。
//检测数据是否合法
var isValid = function (pos, data) {
for (var i=0; i<data.length; i++) {
for (var j=0; j<data[0].length; j++) {
if(data[i][j] != 0) {
if (!check(pos, i, j)){
return false;
}
}
}
}
return true;
}
square.js 中canDown方法和down方法代码:
Square.prototype.canDown = function (isValid) {
var test = {};
test.x = this.origin.x + 1;
test.y = this.origin.y;
return isValid(test, this.data);
}
Square.prototype.down = function () {
this.origin.x = this.origin.x +1;
}
然后在 game.js 下移方法中判断并调用方法:
var down = function () {
if(cur.canDown(isValid)){
clearData();
cur.down();
setData();
refreshDiv(gameData, gameDivs);
}
}
此时效果: 
左移、右移、旋转、空格下落
左移和右移的实现逻辑和下移实现方式差不多,我这里快速的实现一下 game.js 左移和右移方法:
//左移
var left = function () {
if(cur.canLeft(isValid)){
clearData();
cur.left();
setData();
refreshDiv(gameData, gameDivs);
}
}
//右移
var right = function () {
if(cur.canRight(isValid)){
clearData();
cur.right();
setData();
refreshDiv(gameData, gameDivs);
}
}
然后在底部导出 API:
//导出 API this.init = init; this.down = down; this.left = left; this.right = right;
在 square.js 中写对应的左移和右移原型方法:
Square.prototype.canLeft = function (isValid) {
var test = {};
test.x = this.origin.x;
test.y = this.origin.y - 1;
return isValid(test, this.data);
}
Square.prototype.left = function () {
this.origin.y = this.origin.y - 1;
}
Square.prototype.canRight = function (isValid) {
var test = {};
test.x = this.origin.x;
test.y = this.origin.y + 1;
return isValid(test, this.data);
}
Square.prototype.right = function () {
this.origin.y = this.origin.y + 1;
}
然后在 local.js 中绑定键盘事件调用相关方法:
var bindKeyEvent = function () {
document.onkeydown = function (e) {
if (e.keyCode == 38) {
//up
} else if (e.keyCode == 39) {
//right
game.right();
} else if (e.keyCode == 40) {
//down
game.down();
} else if (e.keyCode == 37) {
//left
game.left();
} else if (e.keyCode == 32) {
//space
}
}
}
此时页面效果:
接下来我们实现旋转方法,首先我们的原点是不能动的,动的是这个方块数据,数据我们可以写一个算法让它绕着中间点旋转,但是这样的话,方法有点复杂,所以我们换个思路,我们我们旋转只有四个方向,我们把这四个方向取出来顶一个数组。
//旋转数组
this.rotates = [
[0,2,0,0],
[0,2,0,0],
[0,2,0,0],
[0,2,0,0]
],[
[0,0,0,0],
[2,2,2,2],
[0,0,0,0],
[0,0,0,0]
],[
[0,2,0,0],
[0,2,0,0],
[0,2,0,0],
[0,2,0,0]
],[
[0,0,0,0],
[2,2,2,2],
[0,0,0,0],
[0,0,0,0]
]
这个数组就是它的四个旋转状态。然后我们定义一个方向,方向代表旋转数组中的一个索引,默认是 0。
//方向 this.dir = 0;
旋转逻辑,此时 test 就是我们要改的数据数组,初始化为 0,还需要给索引加 1,然后给 rotates 数组里面相应的索引位置的数据复制给 test,所以需要给索引归位,如果 d 为 4,则重新设置 d 为 0;接着就给 test 赋值,又是一个二层循环
//方向
this.dir = 0;
//旋转数组
this.rotates = [
[
[0,2,0,0],
[0,2,0,0],
[0,2,0,0],
[0,2,0,0]
],
[
[0,0,0,0],
[2,2,2,2],
[0,0,0,0],
[0,0,0,0]
],
[
[0,2,0,0],
[0,2,0,0],
[0,2,0,0],
[0,2,0,0]
],
[
[0,0,0,0],
[2,2,2,2],
[0,0,0,0],
[0,0,0,0]
]
]
旋转逻辑方法:
Square.prototype.canRotate = function (isValid) {
var d = this.dir + 1;
if (d == 4) {
d = 0;
}
var test = [
[0,0,0,0],
[0,0,0,0],
[0,0,0,0],
[0,0,0,0]
];
for (var i=0; i<this.data.length; i++){
for (var j=0; j<this.data[0].length; j++) {
test[i][j] = this.rotates[d][i][j];
}
}
return isValid(this.origin, test);
}
Square.prototype.rotate = function () {
this.dir = this.dir + 1;
if (this.dir == 4) {
this.dir = 0;
}
for (var i=0; i<this.data.length; i++){
for (var j=0; j<this.data[0].length; j++) {
this.data[i][j] = this.rotates[this.dir][i][j];
}
}
}
然后在 game.js 中调用旋转方法:
//旋转
var rotate = function () {
if(cur.canRotate(isValid)){
clearData();
cur.rotate();
setData();
refreshDiv(gameData, gameDivs);
}
}
此时效果如下:
接下来我们做一下坠落功能,坠落就是一直往下直到它不能在向下为止,很简单,我们在 down 方法给他 return 一下,如果它还能向下,那么就return true,else则return false,将它带上一个返回值,如果返回值为 true,表示它还可以在向下,返回值 false 表示它不能再向下。
//下移
var down = function () {
if(cur.canDown(isValid)){
clearData();
cur.down();
setData();
refreshDiv(gameData, gameDivs);
return true;
}else {
return false;
}
}
然后导处 API:
this.fall = function () {while(down());}
最后在 local.js 中绑定键盘事件:
game.fall();
这样以上的四个功能左移、右移、旋转、空格下落就实现了。 
实现七种方块
上面程序只是包含一种方块,接下来我们就把俄罗斯方块的 7 种方块都介入进来,这里我把 squery.js 里的代码复制一份放到 squareFactory.js 中,这样 Square1 就定义好了,然后同样道理定义 Squary2,Square1 和 Squary2 不同的地方在于旋转数组,Squary2 的this.rotates数组如下:
//旋转数组
this.rotates = [
[
[0,2,0,0],
[2,2,2,0],
[0,0,0,0],
[0,0,0,0]
],
[
[2,0,0,0],
[2,2,0,0],
[2,0,0,0],
[0,0,0,0]
],
[
[2,2,2,0],
[0,2,0,0],
[0,0,0,0],
[0,0,0,0]
],
[
[0,2,0,0],
[2,2,0,0],
[0,2,0,0],
[0,0,0,0]
]
]
还有一个问题就是 Square1 和 Square2 的代码大量重复,所以我们需要对代码进行一个优化。在 Square 中下面这段代码如何在 squareFactory.js 中调用呢
//方块数据
this.data = [
[0,0,0,0],
[0,0,0,0],
[0,0,0,0],
[0,0,0,0]
];
//原点
this.origin = {
x: 0,
y: 0
}
//方向
this.dir = 0;
我们可以在 Square1 里面这么写:
Square.call(this);
这个this就是 Square 里面的方块数据、原点、方显相关代码,Square2 我们对它的代码做同样的处理。Squere1~Square7代码一样,变的是旋转数组。
var Square1 = function () {
Square.call(this);
//旋转数组
this.rotates = [
[
[0,2,0,0],
[0,2,0,0],
[0,2,0,0],
[0,2,0,0]
],
[
[0,0,0,0],
[2,2,2,2],
[0,0,0,0],
[0,0,0,0]
],
[
[0,2,0,0],
[0,2,0,0],
[0,2,0,0],
[0,2,0,0]
],
[
[0,0,0,0],
[2,2,2,2],
[0,0,0,0],
[0,0,0,0]
]
]
}
Square1.prototype = Square.prototype;
var Square2 = function () {
Square.call(this);
//旋转数组
this.rotates = [
[
[0,2,0,0],
[2,2,2,0],
[0,0,0,0],
[0,0,0,0]
],
[
[2,0,0,0],
[2,2,0,0],
[2,0,0,0],
[0,0,0,0]
],
[
[2,2,2,0],
[0,2,0,0],
[0,0,0,0],
[0,0,0,0]
],
[
[0,2,0,0],
[2,2,0,0],
[0,2,0,0],
[0,0,0,0]
]
]
}
Square2.prototype = Square.prototype;
...
七个方块都定义好了之后,接下来我们定义一个方块的工厂,同样我们把它作为一个类处理,然后我们在原型链上定义方块方法,传入两个参数index、dir,index代表七个方块的定位,dir代表它旋转的框架。
var SquareFactory = function () {};
SquareFactory.prototype.make = function(index, dir) {
var s;
index = index + 1;
switch(index) {
case 1:
s = new Square1();
break;
case 2:
s = new Square2();
break;
case 3:
s = new Square3();
break;
case 4:
s = new Square4();
break;
case 5:
s = new Square5();
break;
case 6:
s = new Square6();
break;
case 7:
s = new Square7();
break;
default:
break;
}
s.origin.x = 0;
s.origin.y = 3;
s.rotate(dir);
return s;
}
接着要改一下rotate方法,给它传入一个参数num,给这个参数一个初始值,如果说参数没有传的话,参数就等于 1。
Square.prototype.canRotate = function (isValid) {
var d = (this.dir + 1) % 4;
var test = [
[0,0,0,0],
[0,0,0,0],
[0,0,0,0],
[0,0,0,0]
];
for (var i=0; i<this.data.length; i++){
for (var j=0; j<this.data[0].length; j++) {
test[i][j] = this.rotates[d][i][j];
}
}
return isValid(this.origin, test);
}
Square.prototype.rotate = function (num) {
if(!num) num = 1;
this.dir = (this.dir + num) % 4;
for (var i=0; i<this.data.length; i++){
for (var j=0; j<this.data[0].length; j++) {
this.data[i][j] = this.rotates[this.dir][i][j];
}
}
}
然后在 game.js 中用Squery方法时就可以直接调用squareFactory的原型链上的方法:
var init = function (doms) {
gameDiv = doms.gameDiv;
nextDiv = doms.nextDiv;
cur = SquareFactory.prototype.make(2, 2);
next = SquareFactory.prototype.make(3,3);
initDiv(gameDiv, gameData, gameDivs);
initDiv(nextDiv, next.data, nextDivs);
setData();
refreshDiv(gameData, gameDivs);
refreshDiv(next.data, nextDivs);
}
结果显示: 
方块固定、消行、游戏结束判定
到目前为止,这个游戏还没有正常的运行起来,因为方块还没有随着时间下落,接下来我们就实现这部分逻辑。我们打开 local.js 里面,在start里面给它绑定一个定时器:
//开始
var start = function () {
var doms = {
gameDiv: document.getElementById('game'),
nextDiv: document.getElementById('next')
}
game = new Game();
game.init(doms);
bindKeyEvent();
timer = setInterval(move, INTERVAL);
}
设置一个常量INTERVAL定义为 200,每隔 200 触发一次move;定时器定义timer初始值为null
//时间间隔 var INTERVAL = 200; //定时器 var timer = null;
然后我们来定义move,让方块自动下落,直接调用game.down()即可。
//移动
var move= function() {
game.down();
}
由上图可以看出虽然方块实现了自动下落,在它下落到底部的时候应该还有一个逻辑,方块落到底部后将方块位置固定,不可以进行任何操作。在move方法中调用game.fixed():
//移动
var move= function() {
game.down();
game.fixed();
}
fixed方法需要在 game.js 里面去实现它,很简单,对当前这个方块的数据做一个二层遍历,然后check一下点合不合法,如果合法我们再判断一下gameData的位置,如果这个点等于 2 呢,这个时候我就把它赋值成 1,这样就可以固定下来,固定完了之后别忘了调用refreshDiv返回到界面上。
//方块移动到底部,给它固定
var fixed = function () {
for (var i=0; i<cur.data.length; i++){
for (var j=0; j<cur.data[0].length; j++){
if(check(cur.origin, i, j)){
if(gameData[cur.origin.x + i][cur.origin.y + j] == 2) {
gameData[cur.origin.x + i][cur.origin.y + j] = 1
}
}
}
}
refreshDiv(gameData, gameDivs);
}
这样还没完,还需要判断一下方块能否下降:
//移动
var move= function() {
if (!game.down()) {
game.fixed();
}
}
效果显示:
程序到这儿还没有完,还需要我们实现next数据的动态改变,然后实时的反映到game上,如下图:
在move方法中当fixed完毕后,再调用performNext方法,performNext方法需要传入两个参数,一个是下一个方块的种类generateType,一个是下一个方块的循环次数generateDir,我们定义一个函数来完成。
//移动
var move= function() {
if (!game.down()) {
game.fixed();
game.performNext(generateType(), generateDir());
}
}
随机生成一个方块种类-generateType方法,首先使用Math.random()乘以 7 就变成一个 0~7 的数,其次在调用Math.ceil()变成一个 0~7 的整数再减 1,就变成了一个 0~6 的整数,然后 return 返回这个数。
//随机生成一个方块种类
var generateType = function () {
return Math.ceil(Math.random() * 7) - 1;
}
同样道理,generateDir方法:
//随机生成一个旋转次数
var generateDir = function () {
return Math.ceil(Math.random() * 4) - 1;
}
performNext()方法,这个里边的逻辑首先是cur等于next,表示把下一个方块赋给当前方块,其次,调用setData让当前的数据返回到gameData数组里面去,然后 next 方块再生成一个新的方块,最后把它返回到界面上。
//使用下一个方块
var performNext = function (type, dir) {
cur = next;
setData();
next = SquareFactory.prototyoe.make(type, dir)
refreshDiv(gameData, gameDivs);
refreshDiv(next.data, nextDivs);
}
效果如下:
此时我们看到可以玩了,到那时美中不足的是缺少消行功能,接下来我们就一起来实现一下。在 local.js 中,我们想一下消行这个逻辑应该是在方块落在底部固定后即fixed之后完成,我们给这个方法定义为checkClear,在 game.js 中实现这一逻辑。 如何实现呢?我们从gameData的底部往上去发展,如果有一行满足消行的条件,就把它消除,然后把它上面的所有行往下移,这样最上面就会空出一行,那么上面这一行就给它填充 0。具体实现我们还是先循环,从下面gameData.length-1开始,然后再去循环这一行,如果gameData[i][j]不等于 1,那就不可以消除 false;如果这一行可以被消除,我们就来消除这一行,把它上面的行都往下移,最后把第一行变为 0。
//消行方法
var checkClear = function () {
for (var i=gameData.length-1; i>=0; i--) {
var clear = true;
for (var j=0; j<gameData[0].length; j++) {
if(gameData[i][j] !=1){
clear = false;
break;
}
}
//如果可以消除
if (clear) {
for (var m=i; m>0; m--) {
for (var n=0; n<gameData[0].length; n++) {
gameData[m][n] = gameData[m-1][n];
}
}
//第一行变为 0
for (var n=0; n<gameData[0].length; n++) {
gameData[0][n] = 0;
}
i++;
}
}
}
由上图可以看出此时虽然我们实现了消行功能,但是当数据达到顶部时游戏任然继续,这个时候就应该做一个跟踪,到达顶部游戏结束。
所以还需要对程序加一个ckeckGameOver方法,先对数据循环,然后判断从上往下数第二行的数据等于 1 的话,那么gameOver等于 true,意思就是这个数据等于 1,它已经固定下来了,如果在第二行有固定的数据了,再生成其他方块时就装不下了,所以这个时候就gameOver了,最后把gameOver返回。
//检查游戏结束
var ckeckGameOver = function () {
var gameOver = false;
for (var i=0; i<gameData[0].length; i++) {
if (gameData[1][i] == 1) {
gameOver = true;
}
}
return gameOver;
}
在 local.js 中如果gameOver之后我们还需要做一些处理,进行一下监听
//移动
var move= function() {
if (!game.down()) {
game.fixed();
game.checkClear();
var gameOver = game.ckeckGameOver();
if (gameOver) {
stop();
}else{
game.performNext(generateType(), generateDir());
}
}
}
接下来实现一下stop方法,在 stop 方法中,首先我们要clearInterval关掉定时器,timer设为null,同是将键盘事件清掉,document.onkeydown设为null。
//结束
var stop = function () {
if(timer) {
clearInterval(timer);
timer = null;
}
document.onkeydown = null;
}
效果展示:
细节的丰富
到这儿我们整个游戏就可以正常运行了,但是还有一些细节上的问题,比如说我们每次刷新的时候,页面上显示的都是一个固定的方块,这是因为我们在 init 初始化的时候将生成方块写死了,所以我们首先要把这个地方改一下
我们在init函数中传入两个参数,一个是type,一个是dir,首先是next,直接把两个参数传进来即可,cur这个我们不用去生成,然后setData也不需要
//初始化
var init = function (doms, type, dir) {
gameDiv = doms.gameDiv;
nextDiv = doms.nextDiv;
next = SquareFactory.prototype.make(type, dir);
initDiv(gameDiv, gameData, gameDivs);
initDiv(nextDiv, next.data, nextDivs);
refreshDiv(next.data, nextDivs);
}
在 local.js 中我们 init 的时候,我们分别传入两个参数generateType()和generateDir(),在定时器开始前传入performNext方法。
//开始
var start = function () {
var doms = {
gameDiv: document.getElementById('game'),
nextDiv: document.getElementById('next')
}
game = new Game();
game.init(doms, generateType(), generateDir());
bindKeyEvent();
game.performNext(generateType(), generateDir());
timer = setInterval(move, INTERVAL);
}
通过上图我们看到,每次刷新都会改变方块的类型,这样就 OK 了。 接下来我们去实现时间的计时,我们的思路是这样的,我们每隔 200 毫秒都去调用一次move方法,所以我们先定义一个时间计数器timeCount,初始值为 0,在定义一个时间time,然后每次move的时候调用timeFunc方法,接下来我们会具体实现这个方法。 在这个timeFunc方法里面,首先给timeCount加 1,如果timeCount为 5 的话,说明它已经达到一秒了,那么timeCount就给它清零,time加 1,加完 1 之后需要我们把这个time更新到我们的界面上,这个时候我们去调用game里面的一个setTime方法传进去,这个setTime方法接下来我们实现一下。
//计时函数
var timeFunc = function () {
timeCount = timeCount + 1;
if (timeCount == 5) {
timeCount = 0;
time = time + 1;
game.setTime(time);
}
}
setTime 方法在 game.js 中实现,这个setTime我们怎样去更新呢?首先我们需要拿到这个time的div,所以我们定义一个变量timeDiv,通过init里面给它赋值过来timeDiv=doms.timeDiv;,这个doms是通过 local.js 里面 start 方法传进来的。
//设置时间
var setTime = function (time) {
timeDiv.innerHTML = time;
}
看一下效果:
接下来是记分的函数,这个和我们的消行有关系的,假如我们消一行就给加十分,所以我们先找到消行的函数,返回一个它到底消了多少行,我们定义一个变量line初始值为 0,然后在可以消行的地方让它加 1,最后把line返回
//消行方法
var checkClear = function () {
var line = 0;
for (var i=gameData.length-1; i>=0; i--) {
var clear = true;
for (var j=0; j<gameData[0].length; j++) {
if(gameData[i][j] !=1){
clear = false;
break;
}
}
//如果可以消除
if (clear) {
line = line + 1;
for (var m=i; m>0; m--) {
for (var n=0; n<gameData[0].length; n++) {
gameData[m][n] = gameData[m-1][n];
}
}
//第一行变为 0
for (var n=0; n<gameData[0].length; n++) {
gameData[0][n] = 0;
}
i++;
}
}
return line;
}
然后在 local.js 中调用这个checkClear的时候,我们定义一个line,然后去判断,如果line不为 0 的话,调用game.addScore(line)把line传进去。
//移动
var move= function() {
timeFunc();
if (!game.down()) {
game.fixed();
var line = game.checkClear();
if (line) {
game.addScore(line);
}
var gameOver = game.ckeckGameOver();
if (gameOver) {
stop();
}else{
game.performNext(generateType(), generateDir());
}
}
}
然后我们去写这个addScore方法,在addScore传一个参数line,定义一个分数的变量score初始值为 0,再定义一个分数dome元素scoreDiv,然后把scoreDiv加入到init方法中,在 local.js 中start函数方法中把这个 div 传进来。 接下来既要实现addScore方法,定义一个变量s初始值 0,然后switch这个line,假如它为 1 的话,消为一行,加十分,加完之后让score加上s,最后把这个score放到 div 里面去
//加分
var addScore = function (line) {
var s = 0;
switch (line) {
case 1:
s = 10;
break;
case 2:
s = 30;
break;
case 3:
s = 60;
break;
case 4:
s = 100;
break;
default:
break;
}
score = score + s;
scoreDiv.innerHTML = score;
}
效果展示:
还有一个需要优化的就是,当我们方块堆满了整个游戏操作区想让页面有所反应,比如提示语,我在 index.html 里面新添加一个 id 为gameover的 div:
<div class="info">
<div>已用时:<span id="time">0</span>s</div>
<div>已得分:<span id="score">0</span>分</div>
<div id="gameover"></div>
</div>
然后在 game.js 中把这个 div 定义为resultDiv
var resultDiv;
在 game.js 中的init方法里面结束循环
//初始化
var init = function (doms, type, dir) {
gameDiv = doms.gameDiv;
nextDiv = doms.nextDiv;
timeDiv = doms.timeDiv;
scoreDiv = doms.scoreDiv;
resultDiv = doms.resultDiv;
next = SquareFactory.prototype.make(type, dir);
initDiv(gameDiv, gameData, gameDivs);
initDiv(nextDiv, next.data, nextDivs);
refreshDiv(next.data, nextDivs);
}
在 local.js 里面把 doms 传进来
//开始
var start = function () {
var doms = {
gameDiv: document.getElementById('game'),
nextDiv: document.getElementById('next'),
timeDiv: document.getElementById('time'),
scoreDiv: document.getElementById('score'),
resultDiv: document.getElementById('gameover')
}
game = new Game();
game.init(doms, generateType(), generateDir());
bindKeyEvent();
game.performNext(generateType(), generateDir());
timer = setInterval(move, INTERVAL);
}
在move方法里面gameOver的时候,调用一下game.gameover();这个gameover方法就是在界面上显示的信息,我们在 game.js 中实现一下,在中gameover函数方法(记得在下方导出这个方法)我们给它传入一个参数win,代表是否是赢或者是输,然后判断输赢并输出相应的提示语
//游戏结束
var gameover = function (win) {
if (win) {
resultDiv.innerHTML = '你赢了!';
} else {
resultDiv.innerHTML = '你输了!';
}
}
然后我们在 local.js 中的move方法内的gameover传入一个 false。
//移动
var move= function() {
timeFunc();
if (!game.down()) {
game.fixed();
var line = game.checkClear();
if (line) {
game.addScore(line);
}
var gameOver = game.ckeckGameOver();
if (gameOver) {
game.gameover(false);
stop();
}else{
game.performNext(generateType(), generateDir());
}
}
}

增加干扰功能
到这里我们的俄罗斯游戏功能逻辑就基本上实现了,但是由于最终的案例是个双人游戏,那么假设有两个高手在这里玩,大战三天三夜都不分胜负,分数也达到上万分,这样显然是不合理的,所以这就需要我们为游戏增加一个功能。大概思路就是假如我在这里玩游戏玩的比较好,我一次性消了两行,这个时候可以从对方底部往上增加一个干扰,假如我一次性消了三行,可以让对方增加两行干扰,如果我一次性消了四行,那可以让对方增加三行干扰,这样一来游戏就变得刺激更加有挑战性,接下来我们就来实现这个功能。 在 game.js 中加一个addTailLines函数,他有一个参数lines,这个lines是个二维数组,到时候会传进来,就表示我们需要增加的行,记得在底部导出addTailLines这个函数。
这个addTailLines函数逻辑是这样的,首先让所有的行都往上移lines.length高度,然后把底部的行变成lines,我们通过循环来做,然后整行复制,最后记得把方块的位置也移一下,最后将数据返回。
//底部增加行
var addTailLines = function (lines) {
for (var i=0;i<gameData.length - lines.length; i++) {
gameData[i] = gameData[i + lines.length];
}
for (var i=0; i<lines.length; i++) {
gameData[gameData.length - lines.length + i] = lines[i];
}
cur.origin.x = cur.origin.x - lines.length;
if (cur.origin.x < 0) {
cur.origin.x = 0;
}
refreshDiv(gameData, gameDivs);
}
最后我们来看如何调用,在这个 local.js 里面我们先写一个随机生成干扰行的函数generataBottomLine,里面传一个lineNum参数表示要生成几行,generataBottomLine函数主要用来生成addTailLines里面传的lines参数,首先定义一个lines二维数组,然后循环lineNum,在循环内部定义一个line二维数组,再次循环,我在代码里暂时定义 10,表示一行 10 个方块,接下来生成 10 个方块,这 10 个方块都是 0~1 之间的随机数,把这个随机数放入lines数组中农,最后返回这个lines。
//随机生成干扰行
var generataBottomLine = function (lineNum) {
var lines = [];
for (var i=0; i<lineNum; i++) {
var line = [];
for (var j=0; j<10; j++) {
line.push(Math.ceil(Math.random() *2) - 1)
}
lines.push(line);
}
return lines;
}
这个函数就写完毕了,那么什么时候生成这个干扰呢?我们在这个timeFunc函数里面测试一下,假如这个time%10每隔十秒生成一个,调用addTailLines方法生成一行generataBottomLine(1)。
//计时函数
var timeFunc = function () {
timeCount = timeCount + 1;
if (timeCount == 5) {
timeCount = 0;
time = time + 1;
game.setTime(time);
if (time % 10 == 0) {
game.addTailLines(generataBottomLine(1));
}
}
}
效果展示:

对方操作示意
以上就是全部俄罗斯方块游戏的逻辑方法,但是我们的 romote.js 里面还什么都没有,这个里面的逻辑主要是通过Websocket传过来的数据驱动的,但是这节暂时不会涉及到,不过我会给大家做一个简单的示意,首先先对 index.html 进行一个改造。
HTML 代码部分:
<div>请用方向键和空格键进行操作:上->旋转,左->左移,右->右移,下->下移,空格->坠落</div>
<div class="square" id="local">
<div class="title">我的游戏区域</div>
<div class="game" id="local_game"></div>
<div class="next" id="local_next"></div>
<div class="info">
<div>已用时:<span id="local_time">0</span>s</div>
<div>已得分:<span id="local_score">0</span>分</div>
<div id="local_gameover"></div>
</div>
</div>
<div class="square" id="remote">
<div class="title">对方的游戏区域</div>
<div class="game" id="remote_game"></div>
<div class="next" id="remote_next"></div>
<div class="info">
<div>已用时:<span id="remote_time">0</span>s</div>
<div>已得分:<span id="remote_score">0</span>分</div>
<div id="remote_gameover"></div>
</div>
</div>
CSS 代码部分:
.square{
width: 400px;
height: 600px;
float: left;
}
.title{
font-size: 30px;
margin: 20px auto;
}
.game{
width: 200px;
height: 400px;
background-color:#F2FAFF;
border-left: 1px solid blue;
border-right: 1px solid blue;
border-bottom: 1px solid blue;
position: relative;
float: left;
}
.next{
width: 80px;
height: 80px;
background-color: #F2FAFF;
position: relative;
border: 1px solid blue;
float: left;
margin-left: 40px;
}
.info{
float: left;
margin-left: 40px;
margin-top: 10px;
}
.none,.current,.done{
width: 20px;
height: 20px;
position: absolute;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.none{
background-color: #F2FAFF;
}
.current{
background-color: pink;
border: 1px solid red;
}
.done{
background-color: gray;
border: 1px solid black;
}
看一下改造的效果:

对方游戏区域的操作我们先通过按钮模拟,我们在对方 class 的info填入相关按钮。
<div class="square" id="remote">
<div class="title">对方的游戏区域</div>
<div class="game" id="remote_game"></div>
<div class="next" id="remote_next"></div>
<div class="info">
<div>已用时:<span id="remote_time">0</span>s</div>
<div>已得分:<span id="remote_score">0</span>分</div>
<div id="remote_gameover"></div>
<button id="down">down</button><br/>
<button id="left">left</button><br/>
<button id="right">right</button><br/>
<button id="rotate">rotate</button><br/>
<button id="fall">fall</button><br/>
<button id="fixed">fixed</button><br/>
<button id="performNext">performNext</button><br/>
<button id="checkClear">checkClear</button><br/>
<button id="ckeckGameOver">ckeckGameOver</button><br/>
<button id="setTime">setTime</button><br/>
<button id="addScore">addScore</button><br/>
<button id="gameover">gameover</button><br/>
<button id="addTailLines">addTailLines</button><br/>
</div>
</div>
在 remote.js 中,定义一个Remote类,里面包含游戏对象game,开始的方法start,Remote 的start开始方法有两个参数 type 指定生成一个什么样的 next 方块,同样道理先导出start方法,在start方法里先定义dome,然后game.init里面需要我们坚定以的参数传进去,而不是之前的随机生成了。
//开始
var start = function (type, dir) {
var doms = {
gameDiv: document.getElementById('remote_game'),
nextDiv: document.getElementById('remote_next'),
timeDiv: document.getElementById('remote_time'),
scoreDiv: document.getElementById('remote_score'),
resultDiv: document.getElementById('remote_gameover')
}
game = new Game();
game.init(doms, type, dir);
}
生成完之后我们再写一个bindEvents方法,用来绑定按钮事件:
//绑定按钮事件
var bindEvents = function () {
document.getElementById('left').onclick = function () {
game.left();
}
document.getElementById('down').onclick = function () {
game.down();
}
document.getElementById('right').onclick = function () {
game.right();
}
document.getElementById('rotate').onclick = function () {
game.rotate();
}
document.getElementById('fall').onclick = function () {
game.fall();
}
document.getElementById('fixed').onclick = function () {
game.fixed();
}
document.getElementById('performNext').onclick = function () {
game.performNext(2, 2);
}
document.getElementById('checkClear').onclick = function () {
game.checkClear();
}
document.getElementById('ckeckGameOver').onclick = function () {
game.ckeckGameOver();
}
document.getElementById('setTime').onclick = function () {
game.setTime(20);
}
document.getElementById('addScore').onclick = function () {
game.addScore(1);
}
document.getElementById('gameover').onclick = function () {
game.gameover(true);
}
document.getElementById('addTailLines').onclick = function () {
game.addTailLines([[0, 1, 0, 1, 0, 1, 0, 1, 0, 1,]]);
}
}
然后在 script.js 里面调用一下,这里的 start 是需要传参数的,这里我们测试将它写死,然后在调用绑定事件bindEvents方法:
var remote = new Remote(); remote.start(2, 2); remote.bindEvents();
看一下页面效果:

在下章 HTML5 游戏之 Websocket 俄罗斯方块终极版(三)中我们通过 Websocket 发送一些类似于指令的东西,比如说他发送一个 down,我们在接收到这个指令的时候就会调用这个按钮背后相应的函数,查找这样一个逻辑,然后在区域内就会有一个相应的动作,这就是对方游戏区域驱动的一个原理。
下载:HTML5 游戏
结束语
本章内容到这里就全部结束了,这个代码量对于初学者还是有点大的,俄罗斯方块游戏的逻辑也是比较复杂的,但是在整个案例实现的过程中有两个比较关键的地方,首先我们使用到了界面与数据分离的思想,我们在 game.js 里面所有的逻辑操作都是去操作数据gameData,然后去调用refreshDiv把数据返回到界面上,这样使我们的逻辑大大的简化。第二个关键的地方是我们使用了面向对象的思想,在代码里大家会看到我们使用了很多类,我们把这种复杂的逻辑分成一块一块的类,这样可以使我们的程序实用性大大的提高,希望大家看完文章后下去亲自实现一下俄罗斯方块小游戏。在接下来的《HTML5 游戏之 Websocket 俄罗斯方块终极版(三)》中我们会在此基础上加上 Websocket 功能,实现 Websocket 服务端,实现它的通讯,把这个单人的游戏做成双人的游戏,如果有任何问题欢迎下方留言讨论。
以上关于HTML5游戏之Websocket俄罗斯方块进阶版(二)的文章就介绍到这了,更多相关内容请搜索码云笔记以前的文章或继续浏览下面的相关文章,希望大家以后多多支持码云笔记。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 admin@mybj123.com 进行投诉反馈,一经查实,立即处理!
重要:如软件存在付费、会员、充值等,均属软件开发者或所属公司行为,与本站无关,网友需自行判断
码云笔记 » HTML5游戏之Websocket俄罗斯方块进阶版(二)
微信
支付宝
您好,在这篇俄罗斯方块文章二中,关于初始化游戏的函数initGame笔记不全了,能烦请您补充上去或是发送源码给我嘛,正在学习您这篇文章,万分感谢
结束语上面有源码笔记的下载链接。
作者在吗这个俄罗斯方块怎么对战,我的浏览器只能操作一方区域,另一个区域没有办法完成同时进行操作
是按步骤一步一步来得吗