1. 程式人生 > >新手向!超詳細!Java俄羅斯方塊程式面向物件程式設計全記錄

新手向!超詳細!Java俄羅斯方塊程式面向物件程式設計全記錄

零、寫在開始之前

新人,Java學習中,文章中遺漏錯誤之處,歡迎斧正
個人部落格,完全原創
轉載請註明出處。
專案全程式碼地址:GitHub
百度雲盤
提取碼: fnbi

一、從面向物件的開始,將物件抽象成類

面對一個程式or實際專案,個人會選擇從程式的表面入手,從入目可及的例項物件出發,一步步抽象成類。
對於如何使用面向物件的思想去分析一個專案時,我的理解是:

首先去找出物件間的共性,以此來設計出最基本的類。然後找出物件間的差異性,來設計不同的介面或子類來實現這些差異性 <

以俄羅斯方塊程式而言,根據所有的實際物件按照他們共有屬性和方法的不同分類所得的結果,我可以把該程式執行過程中的可見的物件可以分為以下三種類型的型別

疊塊類:各種形態的方塊組合物件
資訊類:面板上可見且變動的資訊物件

對於其中的資訊類物件和背景圖片類物件,因為我們只需要對它們進行展示和賦值,而不需要對它們進行更多的方法操作,賦予它們更多的屬性,因此為了程式更加簡單,我們不需要為它們建立新的類。之後我們會聊到如何使用swing.JFrame來繪製圖片和提示資訊。
對於疊塊類物件的分析我們可以得出它們共有的屬性和方法,並聚合而成一個疊塊類(Tetromino)大致如下

所有的疊塊類物件都是由4個相同大小的小格子組成的
所有的疊塊類物件都有同樣的左移、右移和下落方法

所有的疊塊類物件都可以進行旋轉且暫時假設所有物件遵從相同的旋轉法則(事實確實如此)
由第一條共性中可以提煉出一個不是基本型別也不是任何java自帶類的物件:格子(Cell),為此我們需要設計一個格子類(Cell),它的屬性和方法大致包括:
1.用於定位的row, col值
2.展示的顏色/影象
3.下落,左右移動的最小單元
知道所有的疊塊類物件的共性,但我們也不能因此而忽視它們最直觀的差異所有的疊塊類物件一共擁有七類不同的形狀和顏色,相同形狀的疊塊類物件的格子顏色都相同,所以我們根據差異性建立七個類
綜合以上,我們所需要的基本類和她們所屬的屬性和方法分別為

Cell類

屬性名 型別 修飾詞
row int Privaet
col int Private
image BufferImage Private
方法名 返回型別 傳參 修飾詞
moveDown void - Private
moveLeft void - Private
moveRight void - Private

####Tetromino類

屬性名 型別 修飾詞
cells Cell[] Private
方法名 返回型別 傳參 修飾詞
moveDown void - Public
moveLeft void - Public
moveRight void - Public
spin void - Public

而對於七種疊塊類的子類而言,它們類中沒有特有的屬性,只需利用每一個類的構造方法實現它們的特徵即可。以O類為例,我們建立O類的物件時,我們只需把cells中4個格子物件進行排列就能滿足實現特有形狀,而在建立每一個cells中的Cell物件時傳入的bufferedImage物件則可以實現不同顏色(影象)。

二、類的實現

當經過第一步的使用面向物件將所有物件抽象成類後,我們需要將設計好的類使用程式碼實現。具體的實現程式碼如下

Cell類

Cell類的構造器
Cell類的一般方法

Tetromino類

Tetromino類的方法
Tetromino類中重寫toString方法

特徵類以Z類為例

特徵類以Z類為例

當然我們在類的抽象向還說道Tetromino類中應該擁有旋轉疊塊類物件的方法,因為該方法對每個特徵類都有不同的實現,而且該方法的編寫和除錯離不開圖形實現,因此我們考慮在實現疊塊類的下落和移動後在回過頭實現旋轉方法。

我們已經知道Tetromino類已經不是疊塊類的最表層實現類,與之相反,疊塊類物件的具體實現類因為類物件的不同特徵已經有了七類不同的實現類。這與我們類的抽象的目的——更加精簡的類的實現 相違背。為了精簡優雅的實現疊塊類的初始化,我們可以在Tetromino類中建立一個方法,該方法的具體作用是隨機生成一個數字,然後根據該數字來確定返回的物件型別,具體實現如下所示:
隨機返回疊塊類物件型別方法

三、如何才能畫好一個俄羅斯方塊

接下來設計俄羅斯方塊程式的具體實現類——包括程式的邏輯,演算法,圖形繪製。分以下四個步驟進行靜態程式的繪製
####① 靜態資源的載入
程式中所謂的靜態資源包括圖片,視訊,音訊等,在俄羅斯方塊中必須的是所有的圖片資源。靜態資源的載入一般都在靜態塊中即static塊中載入資源,在本例中載入資源所需要使用的IO類是ImageIO類。所用的方法為該類中的靜態方法ImageIO.read();方法

Public static BufferedImage read(File/InputStream/URL/ ImageInputStream input)Throw IOException{}

假如程式需要讀取Tetris類所在包中的”Z.png”資源

Tetris.class.getResource("Z.png");

又因為ImageIO.read()預設可能會丟擲IOException異常,因此在載入圖片資源時需要使用異常機制嘗試捕獲和處理異常。使用try~catch語句將靜態資源載入語句封裝在一起

Static {
	Try {
			BufferedImage Z = ImageIO.read
				(Tetris.class.getResource("Z.png"));
	}catch (Exception e){
		e.printStackTrace();
	}
}

② 畫背景圖

畫圖需要使用的javax.swing包中的JFrame類和JPanel類。只說明具體的使用方法:

Public Component add(Component comp){}
繼承自Componet的方法,確定視窗的物件

Public void setSize(int width, int height){}
繼承自Window類的方法,約束初始視窗的大小

Public void setUndecorated(true){}
設定視窗邊框不可見,僅顯示圖片

Public void setVisible(true);
設定視窗的可見性,同時呼叫paint()方法

Public void paint(Graphics g){}
呼叫畫筆畫圖或貼圖

g.drawImage(BufferedImage, x, y, null);
貼圖方法的一般用法

綜合使用以上方法我們就可以將執行視窗顯示,並且顯示背景圖
backageImage
main方法中設計視窗

③ 畫運動疊塊類物件(tetromino)和等待進入疊塊類物件(nextone)。

畫tetromino和nextone物件相對於畫背景圖而言,稍稍複雜一些。
首先我們需要先初始化兩個物件

// 下一個下落物件
	private Tetromino nextone;
// 當前下落物件
	private Tetromino tetromino;
	etromino = Tetromino.ranShape();
	nextone = Tetromino.ranShape();

當兩個物件初始化完成後,再分別建立兩個對應的畫物件方法,以paintNextone()為例
paintNextone()
在paint方法中對paintNextone() 和 paintTetromino() 方法進行調00用即可繪製出程式初執行時第一個tetromino物件和nextone物件的影象位置,示例圖該部分討論完列出。

④ 畫提示資訊

在俄羅斯方塊程式的執行過程中,需要在右側三個小框中,寫上提示資訊:SCORE,LINES,LEVEL,對使用者進行提示。
在繪製提示資訊時,我們需要注意的幾個方法和類分別為:
Color類,顏色類;Font類,字型類;
public void setColor(Color color);
設定繪畫筆的的顏色
Public void setFont(Font font);
設定繪畫字的字型
Public void drawString(String str, int x, int y);
繪製提示資訊的方法
最後方法的具體實現如下所示:
paintMsg

當以上所有俄羅斯方塊程式靜態部分的程式碼都完成後,執行程式我們可以得到下面所示的畫面:
靜態畫面

四、讓它們動起來

經過第三步靜態程式的實現,我們可以得到俄羅斯方塊程式執行時第一幀的靜態影象。這當然不是結束,接下來我們需要讓俄羅斯方塊程式動起來。
大家想一想在玩俄羅斯方塊的過程中程式有哪幾個部分或是哪兒幾個物件是會動的。

  1. 下落的疊塊類:tetromino
  2. 觸底堆疊的疊塊類集合:繼續堆疊&消除行
  3. 提示資訊:score,lines,level
    它們之間的聯絡為,tetromino的運動導致觸底判定是否置入堆疊的疊塊類集合wall,wall中判定是否繼續堆疊or消除行的判定導致了score等資訊的更新。因此在物件進行運動Action的繫結時我們可以根據它們間的關係來一步步的實現程式的運動。

① Action方法與定時器和監聽器的繫結

在開始運動之前我們需要將運動方法與定時器進行繫結,定時器的相關類為Timer和TimerTask。

Public void schedule(TimerTask task, long delay, long period);
設定定時器內方法(new TimerTask.run)執行的時間間隔
Public abstract void run();
TimerTask抽象類的抽象方法,因此當程式想用定時器實現定時執行程式碼,需要對run()方法進行實現,而一般的實現方式為匿名內部類。

Timer timer = newTimer();
TimerTask task = new TimerTask() {
@override
Public void run() {
//定時器定時執行部分
}
}
//定時器的定時執行裝置
Timer.schedcule(task, 10, 10);

而除了繫結Action和定時器是物件運動的關鍵外,最終要的時呼叫repaint()方法,該方法的最用時呼叫paint()方法對視窗所有物件進行重畫,也就是利用這個方法在一秒內對視窗進行幾十次的重畫,才能實現物件的運動。當然時相對運動,物件運動的本質是一組靜態物件的集合圖形在短時間內交替改變。
就像時小時候的連環畫,當我們讓連環畫快速的翻動時就會產生連環畫中人物活過來動的感覺。同理其實視訊也是由一秒內幾十上百幀的畫面的切換實現動態的效果。

我們知道俄羅斯方塊疊塊類的運動是與鍵盤的鍵位相繫結的,我們可以需要使用不同的鍵位控制物件運動,並且為了實現運動效果我們同時也需要將按鍵動作與repaint()繫結。在對物件的運動控制中我們需要對以下鍵位進行監聽。

動作 鍵位
下落 KEY_DOWN
左移 KEY_LEFT
右移 KEY_RIGHT
旋轉 KEY_UP

鍵盤監聽類的具體用法一般為匿名內部類來實現KeyListener介面和介面內的KeyPressed(KeyEvent e)抽象方法,使用向上造型的方式將KeyAdapter類物件指向KeyListener介面的引用。在keyPressed(KeyEvent e)內我們可以使用KeyEvent類內的getkeyChar(),getKeyCode()等方法獲取當前觸發按鍵動作所發出的鍵位資訊。根據所獲取的鍵位資訊的不同我們可以呼叫不同的動作(Action)方法來實現物件的不同運動方式。
為了程式碼可維護性和可讀性,我們將根據鍵位資訊來執行不同運動的判斷方法進行封裝。

KeyListener k = new KeyAdapter() {
	@override
	Pulic void KeyPressed(KeyEvent e) {
		Int key = e.getCode();
		KeyMoveAction(key);
	}
}
Public void KeyMoveAction(int key) {
	switch (key) {
		//System.out.println(“這個是鍵位資訊的除錯輸出” + key);
		case KeyEvent.VK_RIGHT:moveRightAction();break;
		case KeyEvent.VK_LEFT:moveLeftAction();break;
		case KeyEvent.VK_DOWN:moveDownAction();break;
		case KeyEvent.VK_UP:spinCellAction();break;
	}
}

② Tetromino的運動實現

下落實現

建立一個moveDownAction()方法,將其呼叫放入上述程式碼的定時器定時執行部分。而其具體實現十分簡單,只要在方法體中讓tetromino物件呼叫其moveDown()方法即可,如下

Public void moveDownAction() {
tetromino.moveDown();
}

但是大家可以發現,疊塊類物件在下落的過程中不會停止,而會一直不停的下落。這時候我們需要設定一個判定條件or判定方法來確定物件下落到何處停止。當然,一個完整的功能最好將其封裝成一個方法。因此我們需要在建立一個isBottom()方法來判斷當前物件是否可以停止下落。
分析物件停止下落的條件:

  1. 疊塊類物件觸底
  2. 疊塊類物件底部接觸到其他疊塊類物件集合

在對下落條件進行具體分析前,我們需要宣告一個二維陣列規定下tetromino的運動範圍,同時也可以作為儲存停止運動後的格子容器。

Private static int ROWS = 20;
Private static int COLS = 10;
Private Cell[][] wall = new Cell[20][10];//格子容器

在得到一個空wall陣列後,我們分析下落停止的條件。
第一個條件,疊塊類物件觸底即意味著,當前物件中cells內的4個格子有一個或幾個到達wall陣列的最底端也就是某一Cell物件的row值等於最大值(ROWS-1)。
第二個條件,疊塊類物件中某一Cell物件接觸到了其他Cell物件集合(即wall陣列的頂部)。對於疊塊類物件的cells內的一個或多個格子接觸到了wall陣列中的已存在的格子物件,即cells內的某個格子下方存在格子物件。可以根據當前格子下方即wall[row+1][col]是否存在格子類判定接觸。
綜合以上兩個判定條件我們需要對疊塊類物件中cells陣列的格子物件進行遍歷,然後根據不同情況進行判定。具體程式碼如下
isBottom()
最後綜合isBottom()方法對下落方法moveDownAction()進行修改可有

Public void moveDownAction() {
	If(!isBottom) {
		tetromino.moveDown();
	}
}
左右移動實現

建立兩個方法moveLeftAction()&moveRightAction()。與moveDownAction()類似,我們在方法體中使用tetromino.moveLeft()/ tetromino.moveRight()方法。同樣的我們也需要面對左右移動的邊界問題。下落實現時分析的判定條件的基礎上我們可以知道,左右移動實現同樣面對著這兩個問題。

  1. 左右移動到達邊界問題;
  2. 左右移動接觸到已有格子物件問題。
    而且因為左移與右移的判定條件相似但是完全相反,因此我們需要同時建立兩個對應的判定方法canLeftMove() & canRightMove()。結合分析問題得出的條件和下落時的判定方法isBottom()的設計。以右移和其判定方法為例子
    canrightMove
    moveRight
tetromino旋轉方法的實現

旋轉方法時俄羅斯方塊中最重要的方法之一,它是實現了俄羅斯方塊如此精彩耐玩的基礎。在對旋轉方法進行設計之前,需要對俄羅斯方塊中各種疊塊類的旋轉特點進行分析和比較,經過分析,所有疊塊類都可以分成兩類,適用與兩種不同的演算法:

  1. O型物件,因為O型物件屬於圓心對稱,因此無論O型物件如何旋轉都不會改變形態
  2. 其他型物件,是的其他型別物件的旋轉形式各種各樣,但是有句話說得好,萬變不離其宗,當我們在物件的四個格子中選擇一個作為圓心即旋轉中心點時,各個旋轉格子物件與旋轉中心點的關係如下所有

A = iRow - iCol + b;
B = iRow + iCol - a;

旋轉中心點O(iRow, iCol),格子旋轉前M(a, b),格子旋轉後N(A, B);

找到旋轉方法所需要的演算法後,就可以開始進行旋轉方法的設計和實現了,但是在實現方法之前我們需要明確以下問題

當前物件是否可以進行旋轉?or 物件何時可以進行旋轉?

我們在對物件進行下落,左右移動的操作時都有關注物件是否可以繼續左右移動或下落的判定,那麼同樣的我們在對物件進行旋轉操作時也不可以忽略對物件進行相關問題的研究和討論。結合下落判定和左右移動判定的經驗,俄羅斯方法疊塊類物件旋轉方法有以下判定條件

  1. 當物件旋轉時,旋轉結果物件的cells內是否有格子越界,即超出wall陣列的邊界
  2. 當物件旋轉時,旋轉結果物件的cells內是否有格子與wall內已存在元素重疊

以上兩個判定條件都需要或是依賴於對旋轉結果與wall陣列的比較,而這就產生了一個問題,我們的tetromino.spin()方法定義於Tetromino類中,而判定條件需要的wall陣列存在於Tetris類中且屬於類的私有屬性。Tetromino的spin()方法中無法呼叫Tetris類中的私有屬性wall陣列,無法做到在spin()方法中對結果進行判定。因此在此處我們需要將tetromino.spain()的旋轉結果以Cell陣列的形式返回到Tetris類中,然後根據判定條件對返回結果進行判定,若判定結果為true,則將tetromino.cells指向返回物件的引用,否則return結束當前方法的執行。

以上分析我們可以知道需要至少兩個方法來進行合理的旋轉運動
public Cell[] spin(){}
public void spinCellAction(){}
其他注意點:
在定義spin()方法,我們需要注意的是:

  1. 在初始化iCells陣列時不可以將this.cells直接指向iCells陣列的引用
  2. 必須對iCells陣列元素進行初始化,不然返回結果中的Cell元素會產生NullPointException異常。
    旋轉方法
    而在設計實現spinCellAction()方法時,我們需要注意:
    1. 判定條件的先後問題
    2. Wall陣列的邊界問題
      判定方法

③ Wall陣列中元素儲存與刪除

其實這個步驟的一部分應該在左右移動和下落運動時就需要實現了,那就是把停止下落的疊塊類物件中cells的格子物件存入wall陣列中對應的位置。
Wall陣列元素的存入,當某疊塊類物件停止下落時,我們可以遍歷該物件cells內的格子元素,並將它們存入到wall陣列中對應的位置。這部分程式碼在之前的isBottom()方法內已經實現。虛擬碼示意如下

//當確定當前物件運動到底部即停止時,將該物件的cells內的格子元素存入wall內
for(int j = 0; j < cells.length; j++) {
	Cell cell = cells[j];
	int col1 = cell.getCol();
	int row1 = cell.getRow();
	wall[row1][col1] = cell;
}

當前tetromino物件停止運動並存入wall陣列內時,我們需要將nextone的物件賦給tetromino物件,同時還需要將nextone物件重新初始化,即nextone = Tetromino.ranShape(),實現更新tetromino和nextone物件的作用。以上是wall陣列的儲存原理和方法。

Wall陣列消除行方法。先建立一個名為removeLine()的方法,每此當wall陣列內元素有變化即wall內新存入元素時呼叫removeLine()方法。
removeLine()方法的設計是簡單的雙迴圈判定方法。設定一個特徵值比如
Boolean flag = true;
外迴圈為row[020),內迴圈為col[010),每當內迴圈開始時,若存在wall[row][col] == null則設定flag = false,跳出當前內迴圈,更新flag = truerow++,重新開始內迴圈。若完成一個內迴圈後flag == true,則設定當前row下的所有元素wall[row][0~9] = null。同時將小於當前row的所有行下降即row-1。最後為了實現資訊的更新將Scone+=10lines+=1
removeLine

④ 提示資訊的更新

在“wall陣列中的元素儲存與消除”中已經包括了對提示資訊的更新。SCORE和LINES只要要自增就可以了。

score += 10;
lines += 1;

但是對於LEVEL遊戲難度而言,它的改變還是需要進行一番規定的。首先LEVEL資料改變的條件是lines/10 == 0,也就是說每當消除10行格子後就可以改變LEVEL值。但是在此程式中我們的採用LEVEL來控制格子速度speed = 5*LEVEL,所以LEVEL值時從高到低,格子移速越來越快,而LEVEL最低不能為0,否則speed會變為0.所以綜合起來LEVEL的演算法如下
level = lines%10 == 0?level == 1?level:level-1:level;
當然此時更新的是後臺資料,要想實現可見的資訊更新仍舊需要repaint()和paintTabs()兩個方法的組合配合。

五、控制它們,不受控制的程式是不可靠的

對於一組程式,與我而言,最重要的工作不是讓它動起來,而是如何控制它的運動。不受控制的程式是不穩定的。當然現在寫的只是簡單的俄羅斯方塊小程式而已,233。
如何控制一組俄羅斯方塊程式,除了以上控制運動章節所提及的物件地運動控制之外,還有程式整體的狀態控制,比如暫停,初始化,退出等等狀態或功能。將它們的觸發與退出與鍵盤的鍵位進行繫結

狀態or功能 鍵位
暫停(Pause) P
繼續(Continue) C
初始化(initialize) I
遊戲結束(gameover) null
退出(Exit) E

在Tetris類中宣告暫停or正常執行的特徵值STATE,預設值為true。根據鍵位監聽器方法的設定和控制狀態的要求,我們在keyMoveAction()方法中加入如下程式碼:

case KeyEvent.VK_I:moveInitAction();break;
case KeyEvent.VK_P:STATE = false;break;
case KeyEvent.VK_C:STATE = true;break;
case KeyEvent.VK_E:System.exit(0);break;

因為在初始化時需要重置的物件與資訊較多,因此將這部分程式碼封裝成一個方法,方便後期的除錯和呼叫。
初始化

而對於遊戲結束狀態而言時不需要使用鍵盤進行控制的,它需要的對遊戲是否可以繼續進行的判定,以及最重要的是對遊戲結束時遊戲程式介面的重畫以及提示資訊GameOver的展示。同理我們也需要對暫停狀態的遊戲程式介面進行重畫。為此我們需要在paint()方法中增加兩個重畫方法paintGameOver() & paintGamePause()。對它們的呼叫依據為新增方法isGameOver()的返回結果和STATE的值。

如何實現暫停,實現暫停方法的邏輯很簡單就是當程式進入暫停模式時,將物件運動相關的方法全部掛起即可,此程式中進入暫停狀態的標識是 STATE = false。也就可以在定時器中新增一個條件判斷語句,當程式處於正常狀態條件成立即可執行程式,正常執行action相關方法,若STATE為false則將程式中的運動方法掛起,等待下一次狀態改變。

if (STATE) {
	if (moveIndex % speed == 0) {
		moveDownAction();
		moveIndex = 0;
	}
}

isGameOver()方法的作用為判斷當前遊戲程序是否可以繼續進行,該判斷的依據於wall陣列內當row = 0的行內是否存在格子。若存在返回true示意遊戲結束,否則返回false。

public boolean isGameOver() {
	for(int col = 0; col < COLS; col++) {
		if (wall[0][col] != null)return true;
	}
	return false;
}

而對於paintGameOver() & paintGamePause()而言,兩者是否需要繪製都依據於對STATE值和isGameOver的值的判定
paintGameOver
paintGamePause

同時因為狀態和功能之間有相斥或依賴的關係,比如繼續”continue”功能必須時程式處於暫停”pause”時才有效,遊戲結束”gameOver時,則需要進行初始化設定,而無法對程式進行暫停處理或”continue”繼續處理。這些程式間的邏輯關係和細節處理需要進一步的分析。還可以對程式的更多可能性狀態進行新增。

六、讓程式看起來更有趣

在很多時候看來,程式設計從來都是一項枯燥無聊的活動,螢幕的幽幽藍光映鏡片,一天要坐著至少9h,多可怕。如果我們不能從程式中找到更多的樂趣(惡趣),那堅持說起來簡單做起來真的時太難了。就算無法完成,但至少想起可樂就行。
① 外觀大改造,PS,DIY更有趣的程式介面和背景圖片
當圖片輪換條件滿足時,改變程式的背景圖片
背景圖片的靜態資源載入
背景圖片的輪換的條件——輪換條件可以與isBottom(){}removeLine(){}
keyPressed(Event e){}等方法進行繫結,當方法中的條件成立時圖片改變。為了簡化程式的程式碼量,我們藉助與變數BGI來完成圖片輪換。

  //控制背景圖片
 BGI = (BGI == 3)?0:BGI+1;
 g.drawImage(bgi[BGI], 0, 0, null);

這裡寫圖片描述
當然還可將程式配上個性音效辣,手動設定遊戲難度等等。