一、從資料出發還是從介面出發
要寫一個俄羅斯方塊小遊戲,我們先來一塊考慮一下下面幾個問題:
#1 、用什麼表示方塊
2、怎麼設定或改變方塊的顏色
3、怎麼移動方塊
4、怎麼消除方塊
請考慮一分鐘後再繼續往下看。 。 。 。 。 。
如果你對上面幾個問題思考,每一個答案都和介面、控件、平台有關的話,就是說假如你是用.Net 的,你的每一個答案都是圍繞著如何利用控件、如何使用窗體、在控制項的哪個事件裡面改變哪個屬性等等,那麼說明你被微軟的RAD 開發環境毒害的不淺,我建議你立刻丟掉Visual Studio,改用其他輕量級的程式語言和開發平台,這樣你可以更多的關注問題的本身,而不是控制。
記住:程式 = 資料結構 + 演算法
介面只是資料的表象,而資料才是問題的本質。
下面,我們將一步一步建立一個俄羅斯方塊小遊戲的資料模型,當整個模型建立完畢後,我們會發現,雖然沒有介面,但仍然不妨礙這是一個功能完整的俄羅斯方塊遊戲,因為發生的每一件事情都很清楚,我們只是沒把它畫而已。當然,後面我們會給出一個操作簡易的介面,等到下一篇,會專門探討介面的問題。
二、「形狀」的資料模型
俄羅斯方塊是一個經久不衰的小遊戲,最常見的版本一般有七個形狀,分別是:
直線型、S型、Z型、L型、反L型、T型、方形等,如下圖:
那麼我們在程式中如何表示這七個形狀呢?我們發現每個形狀都是由四個小方塊組成的,我們完全可以用四個點來表示。
但是問題又來了,四個點的座標分別是什麼呢?我查到的方法是:每個形狀都有一個自己的座標系,像是S型,可以入下圖表示:
這樣,S型的資料模型可以表示為四個點所組成的陣列:[ [ 0, -1 ], [ 0, 0 ], [ -1, 0 ], [ -1, 1 ] ] 。
我們可以用同樣的方法建立其他形狀的陣列模型,然後再將這七個形狀的陣列模型合起來組成一個大的陣列。
另外,每個形狀可以是單色,也可以有自己的顏色。增加顏色會增加程式的複雜度,但也增加不了多少,所以我們的模型中也會考慮顏色。
最後,我們最好給每個形狀一個編號,這樣方便在形狀數組和顏色數組中應用他們。
完成上面的分析後,我們就可以給出形狀資料模型的程式碼了:
形状模型的代码 Code highlighting produced by Actipro CodeHighlighter (freeware) http://www.CodeHighlighter.com/ -->//各种形状的编号,0代表没有形状 NoShape=0; ZShape=1; SShape=2; LineShape=3; TShape=4; SquareShape=5; LShape=6; MirroredLShape=7 //各种形状的颜色 Colors=["black","fuchsia","#cff","red","orange","aqua","green","yellow"]; //各种形状的数据描述 Shapes=[ [ [ 0, 0 ], [ 0, 0 ], [ 0, 0 ], [ 0, 0 ] ], [ [ 0, -1 ], [ 0, 0 ], [ -1, 0 ], [ -1, 1 ] ], [ [ 0, -1 ], [ 0, 0 ], [ 1, 0 ], [ 1, 1 ] ], [ [ 0, -1 ], [ 0, 0 ], [ 0, 1 ], [ 0, 2 ] ], [ [ -1, 0 ], [ 0, 0 ], [ 1, 0 ], [ 0, 1 ] ], [ [ 0, 0 ], [ 1, 0 ], [ 0, 1 ], [ 1, 1 ] ], [ [ -1, -1 ], [ 0, -1 ], [ 0, 0 ], [ 0, 1 ] ], [ [ 1, -1 ], [ 0, -1 ], [ 0, 0 ], [ 0, 1 ] ] ];
三、定位和旋轉形狀
1、定位
我們上面說到每個形狀都是在自己的座標系裡面描述的,另外還有一個全局座標系,用來給形狀定位,這樣我們就需要一個方法將形狀的四個點從自身座標系轉換到全域座標系,從而為形狀定位。
假如S型在自身座標系中四個點的座標為:[ [ 0, -1 ], [ 0, 0 ], [ -1, 0 ], [ -1, 1 ] ]
它目前在全域座標系位置為:[12,8]
則,四個點轉換為全域座標系的座標為:[ [ 0+12, -1+8 ] , [ 0+12, 0+8 ], [ -1+12, 0+8 ], [ -1+12, 1+8 ] ]
這樣,我們就完成了S型的全域座標轉換。
這裡需要注意一個問題,形狀自身座標係是用(x,y) 描述的,而全局座標系為了邏輯上更直觀,是用(row,col) 描述的,所以我們在實際程式設計中並不是往上面那樣轉換的,而是:
[ [ -1+12, 0+8 ], [ 0+12, 0+8 ], [ 0+12, -1+8 ], [ 1+12, -1+8 ] ]
即:先將x 變成col ,y 變成row ,再轉換為全域座標系。
2、旋轉
旋轉是在形狀的自身座標系中,並圍繞形狀的原點完成的,公式很簡單,每點旋轉後的座標與旋轉前座標的關係如下(向右旋轉):
x' = y
y' = -x
##注意:方塊形狀不會發生旋轉。
有了上面的分析,我們就可以給出兩個全局方法,他們用來對形狀進行全局定位和旋轉:全局定位和旋转的代码 Code highlighting produced by Actipro CodeHighlighter (freeware) http://www.CodeHighlighter.com/ -->//将形状自身的坐标系转换为 Map 的坐标系,row col 为当前形状原点在 Map 中的位置 function translate(data,row,col){ var copy=[]; for(var i=0;i<4;i++){ var temp={}; temp.row=data[i][1]+row; temp.col=data[i][0]+col; copy.push(temp); } return copy; } //向右旋转一个形状:x'=y, y'=-x function rotate(data){ var copy=[[],[],[],[]]; for(var i=0;i<4;i++){ copy[i][0]=data[i][1]; copy[i][1]=-data[i][0]; } return copy; }
四、移動空間
前面我们说过,形状是由四个点组成的,而形状的移动空间也是由 m * n 个点组成的一个二维数组。
这里为了更直观的描述,我将 n 个点组成一条线 Line,再将 m 条 Line 组成形状的移动空间,我把它叫做 Map 。
我们有了这 m * n 个点有什么用呢?用处很简单,就是保存形状的编号,如果一个点没有被形状占用,则编号为 NoShape。这就是前面给出形状编号的用处,同时也是为什么要有一个 NoShape 编号的原因。
Map 应该具有什么功能呢?下面我列举了一些:
1、构造函数:这不用说了,n 个点组成一行 Line, m 行 Line 组成Map,每个点初始化成 NoShape
2、newLine:生成新的一行。为什么需要这个方法呢,因为除了构造函数中,游戏运行过程中我们也需要用到它,当一行或者几行被消除以后,我们需要在顶部假如一行或者几行新的Line
3、isFullLine(row):这个方法用来判断第 row 行是否满了,每次一个形状落地后,就需要对每一行进行这个判断,满了当然是消除了。
4、isCollide(data): data 是一个定位后的形状数据,这样我们就可以检查这些数据是否超出移动空间的上下左右边界,另外还检查数据的四个点是否已经被占用,这就是碰撞检测。
5、appendShape(shape_id,data):当一个形状落地以后,我们就应该将运行空间中某些点的值改变为这个形状的编号,我把这称为占用。
6、消除操作:这个功能没有单独列为一个方法,我把它放在 appendShape 方法中了。消除操作也很简单,发现某一行 isFullLine 了以后,在 lines 数组中移除这一行,并在 lines 数组的顶部加入一个空行即可。
有了上面的分析,我们就可以给出移动空间的代码了:
移动空间的代码 Code highlighting produced by Actipro CodeHighlighter (freeware) http://www.CodeHighlighter.com/ -->/* * 说明:由 m 行 Line 组成的格子阵 */ function Map(w,h){ //游戏区域的长度和宽度 this.width=w; this.height=h; //生成 height 个 line 对象,每个 line 宽度为 width this.lines=[]; for(var row=0;row<h;row++) this.lines[row]=this.newLine(); } //说明:间由 n 个格子组成的一行 Map.prototype.newLine=function(){ var shapes=[]; for(var col=0;col<this.width;col++) shapes[col]=NoShape; return shapes; } //判断一行是否全部被占用 //如果有一个格子为 NoShape 则返回 false Map.prototype.isFullLine=function(row){ var line=this.lines[row]; for(var col=0;col<this.width;col++) if(line[col]==NoShape) return false return true; } /* * 预先移动或者旋转形状,然后分析形状中的四个点是否有碰撞情况: * 1:col<0 || col>this.width 超出左右边界 * 2:row==this.height ,说明形状已经到最底部 * 3:任意一点的 shape_id 不为 NoShape ,则发生碰撞 * 如果发生碰撞则放弃移动或者旋转 */ Map.prototype.isCollide=function(data){ for(var i=0;i<4;i++){ var row=data[i].row; var col=data[i].col; if(col<0 || col==this.width) return true; if(row==this.height) return true; if(row<0) continue; else if(this.lines[row][col]!=NoShape) return true; } return false; } //形状在向下移动过程中发生碰撞,则将形状加入到 Map 中 Map.prototype.appendShape=function(shape_id,data){ //对于形状的四个点: for(var i=0;i<4;i++){ var row=data[i].row; var col=data[i].col; //找到所在的格子,将格子的颜色改为形状的颜色 this.lines[row][col]=shape_id; } //======================================== //形状被加入到 Map 中后,要进行逐行检测,发现满行则消除 for(var row=0;row<this.height;row++){ if(this.isFullLine(row)){ //将满的那一行替换成新的空,这一步主要是为了显示效果,可以不要! //this.lines[row]=null; //重绘 Map 消除效果 //onClearLine(row); //将满行删除 this.lines.splice(row,1); //第一行添加新的一行 this.lines.unshift(this.newLine()); //重绘 Map 整行下落效果 onDraw(this.lines); } } }
五、游戏模型
我们有了游戏的数据模型,我们就可以读写他们了。所谓读好理解,所谓写就是改变他们,改变的方法当然是用户的操作了。
下面给出 GameModel 类,他维护三个主要的数据:
1、一个形状的编号,就是用户可以操作移动的那个形状
2、形状的全局位置,用 row col 表示
3、一个 Map,用它完成碰撞检测,添加等操作
另外,还抽象出几个用户的操作动作:
1、left:左移。将形状的全局坐标 col 减少 1 。请思考一下,这样就可以了吗?当然不行,我们还需要进行碰撞检测,如果已经在最左边,则放弃处理。
2、right:右移。同上。
3、rotate:旋转。同上。
4、down:下落。同上。下落过程中的碰撞检测有所不同,一旦发生碰撞,我们不能再放弃处理了,而是要将当前形状加入到空间中。
5、GameOver:下落过程中还需要进行一个检测就是游戏是否结束。如果当前形状在出生地点刚一下落就发生碰撞,说明已经到顶部了,则游戏结束。
有了上面的分析,我们就可以给出 GameModel 的代码:
GameModel 代码 Code highlighting produced by Actipro CodeHighlighter (freeware) http://www.CodeHighlighter.com/ -->/* * 说明:GameModel 类 */ function GameModel(w,h){ this.map=new Map(w,h); this.born(); } //出生一个新的形状 GameModel.prototype.born=function(){ //随机选择一个形状 this.shape_id=Math.floor(Math.random()*7)+1; this.data=Shapes[this.shape_id]; //重置形状的位置为出生地点 this.row=1; this.col=Math.floor(this.map.width/2); //通知绘制移动效果,传回数据为形状的四个点在 Map 中的位置 onMove(this.shape_id,this.map,translate(this.data,this.row,this.col)); } //向左移动 GameModel.prototype.left=function(){ this.col--; var temp=translate(this.data,this.row,this.col); if(this.map.isCollide(temp)) //发生碰撞则放弃移动 this.col++; else //通知绘制移动效果,传回数据为形状的四个点在 Map 中的位置 onMove(this.shape_id,this.map,temp); } //向右移动 GameModel.prototype.right=function(){ this.col++; var temp=translate(this.data,this.row,this.col); if(this.map.isCollide(temp)) this.col--; else onMove(this.shape_id,this.map,temp); } //旋转 GameModel.prototype.rotate=function(){ //正方形不旋转 if(this.shape_id==SquareShape) return; //获得旋转后的数据 var copy=rotate(this.data); //转换坐标系 var temp=translate(copy,this.row,this.col); //发生碰撞则放弃旋转 if(this.map.isCollide(temp)) return; //将旋转后的数据设为当前数据 this.data=copy; //通知绘制移动效果,传回数据为形状的四个点在 Map 中的位置 onMove(this.shape_id,this.map,translate(this.data,this.row,this.col)); } //下落 GameModel.prototype.down=function(){ var old=translate(this.data,this.row,this.col); this.row++; var temp=translate(this.data,this.row,this.col); if(this.map.isCollide(temp)){ //发生碰撞则放弃下落 this.row--; //如果在 1 也无法下落,说明游戏结束 if(this.row==1) { //通知游戏结束 //onGameOver(); alert("Game Over") return; } //无法下落则将当前形状加入到 Map 中 this.map.appendShape(this.shape_id,old); //出生一个新的形状 this.born(); } else //通知绘制移动效果,传回数据为形状的四个点在 Map 中的位置 onMove(this.shape_id,this.map,temp); }
六、一个简单的操作界面
虽然到现在为止,我们没有给出一行和界面有关的代码,但是整个游戏在逻辑上已经完全可以运行起来了,只是我们没有把他画出来而已,要想把他画出来也很简单。
注意上面给出的代码中很多地方调用了两个全局函数:onDraw 和 onMove ,这两个函数就是用来进行绘制的。
绘制的代码其实只占很少的一部分,其中一些绘图函数我为了方便对 HTML5 的 2D 函数进行了简单的封装,您完全可以用原生的 HTML5 函数,或者用您自己平台的绘图函数,因为他们本身不是太复杂。
另外有一个全局变量 Spacing ,他表示一个格子的宽度。
下面给出操作界面的代码:
界面操作代码 Code highlighting produced by Actipro CodeHighlighter (freeware) http://www.CodeHighlighter.com/ -->//每一格的间距,也即一个小方块的尺寸 Spacing=20; //在内存中绘制一个小方块 function drawRect(color){ var temp=new Surface(Spacing,Spacing,"rgba(255,255,255,0.2)");//背景色 temp.fillRect(1, 1, Spacing-2, Spacing-2, color);//前景色 return temp; } var display= Display.attach(document.getElementById("html5_09_1")); var model = new GameModel(display.width/Spacing,display.height/Spacing); function onDraw(map){ //清屏 display.clear(); var lines=map.lines; //依次绘制每一个非空的格子 for(var row=0;row<map.height;row++) for(var col=0;col<map.width;col++){ var shape_id=lines[row][col]; if(shape_id!=NoShape){ var rect = drawRect(Colors[shape_id]); var y=row * Spacing; var x=col * Spacing; display.draw(rect, x, y); } } } function onMove(shape_id,map,data){ onDraw(map); //绘制当前的形状 for(var i=0;i<4;i++){ var y=data[i].row * Spacing; var x=data[i].col * Spacing; var rect = drawRect(Colors[shape_id]); display.draw(rect, x, y); } } function down(){ model.down(); } function left(){ model.left(); } function right(){ model.right(); } function rotate_click(){ model.rotate(); }
七、如何改进
到现在为止,程序已经基本能运行起来了,但是还没有加入键盘操作,另外还有一个很大的问题就是:程序有时候会“算死”。为什么会出现这个现象呢?
做个实验,不管你用什么平台,你用绘图函数,先清屏,然后随机绘制一条直线,连续循环1000次。你会发现,前面999次,并看不到清屏和绘制效果,而且程序都会失去响应,等到1000次完成后,你才能看到最后一条直线,程序重新接受响应。这就是“算死”,解决的方法就是把绘制动作放在计时器或者线程里面,到下一篇,我们会解决这个问题。
以上是HTML5邊玩邊學(九)-俄羅斯方塊之資料模型篇的詳細內容。更多資訊請關注PHP中文網其他相關文章!