特別提示:
一、從數據出發還是從界面出發 要寫一個俄羅斯方塊小游戲,我們先來一塊考慮一下下面幾個問題: 1、用什么表示方塊 2、怎么設置或者改變方塊的顏色 3、怎么移動方塊 4、怎么消除方塊 請考慮一分鐘后再繼續向下看。。。。。。
如果你對上面幾個問題思考,每一個答案都和界面、控件、平臺有關的話,就是說假如你是用 .Net 的,你的每一個答案都是圍繞著如何利用控件、如何使用窗體、在控件的哪個事件里面改變哪個屬性等等,那么說明你被微軟的 RAD 開發環境毒害的不淺,我建議你立刻扔掉 Visual Studio,改用其他輕量級的編程語言和開發平臺,這樣你可以更多的關注問題的本身,而不是控件。 記?。?strong>程序 = 數據結構 + 算法 |
界面只是數據的表象,而數據才是問題的本質。
下面,我們將一步一步建立一個俄羅斯方塊小游戲的數據模型,當整個模型建立完畢后,我們會發現,雖然沒有界面,仍然不妨礙這是一個功能完整的俄羅斯方塊游戲,因為發生的每一件事情都很清楚,我們只是沒把它畫而已。當然,后面我們會給出一個操作簡易的界面,等到下一篇,會專門探討界面的問題。
二、“形狀”的數據模型
俄羅斯方塊是一個經久不衰的小游戲,最常見的版本中一般有七個形狀,分別是:
直線型、S型、Z型、L型、反L型、T型、方形等,如下圖:
那么我們在程序中如何表示這七個形狀呢?我們發現每一形狀都是四個小方塊組成的,我們完全可以用四個點表示。
但是問題又來了,四個點的坐標分別是什么呢?我查到的方法是:每個形狀都有一個自己的坐標系,比如S型,可以入下圖表示:
這樣,S型的數據模型可以表示為四個點組成的數組:[ [ 0, -1 ], [ 0, 0 ], [ -1, 0 ], [ -1, 1 ] ] 。
我們可以用同樣的方法建立其他形狀的數組模型,然后再將這七個形狀的數組模型合起來組成一個大的數組。
另外,每個形狀可以是單色,也可以有自己的顏色。增加顏色會增加編程的復雜度,但是也增加不了多少,所以我們的模型中也會考慮顏色。
最后,我們最好給每個形狀一個編號,這樣方便在形狀數組和顏色數組中應用他們。
完成上面的分析后,我們就可以給出形狀數據模型的代碼了:

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
注意:方塊形狀不發生旋轉。
有了上面的分析,我們就可以給出兩個全局方法,他們用來對形狀進行全局定位和旋轉:

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 數組的頂部加入一個空行即可。
有了上面的分析,我們就可以給出移動空間的代碼了:

* 說明:由 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 類
*/
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 ,他表示一個格子的寬度。
下面給出操作界面的代碼:

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();
}
HTML 代碼很簡單,也給出來吧,就一塊畫布和四個按鈕,如下:

你的瀏覽器不支持 Canvas 標簽,請使用 Chrome 瀏覽器 或者 FireFox 瀏覽器
</canvas><p/>
<input type="button" value="向下" onclick="down()"/>
<input type="button" value="向左" onclick="left()"/>
<input type="button" value="向右" onclick="right()"/>
<input type="button" value="旋轉" onclick="rotate_click()"/>
七、運行效果
{{{{{{
}}}}}}
八、如何改進
到現在為止,程序已經基本能運行起來了,但是還沒有加入鍵盤操作,另外還有一個很大的問題就是:程序有時候換“算死”。為什么會出現這個現象呢?
做個實驗,不管你用什么平臺,你用繪圖函數繪制先清屏,然后隨機繪制一條直線。你會發現,前面999次,并看不到清屏和繪制效果,而且程序都會失去響應,等到1000次完成后,你才能看到最后一條直線,程序重新接受響應。這就是“算死”,解決的方法就是把繪制動作放在計時器或者線程里面,到下一篇,我們會解決這個問題。
//==========================================