2048小遊戲(Java)原始碼解析及原始碼打包
資料結構課程設計寫的2048小遊戲,答辯完了就開源了,因為這次的技術文件任性地寫成了傻瓜式教程了,就乾脆也放出來了,供參考,原始碼打包在最後面會附上。
一、 實現方案
本遊戲採用Java語言編寫,使用Eclipse編譯器, jdk1.7.0_51編譯環境。
遊戲的UI主要運用Java圖形介面程式設計(AWT),實現視窗化視覺化的介面。
遊戲的後臺通過監聽鍵盤方向鍵來移動數字方塊,運用隨機數的思想隨機產生一個2或4的隨機數,顯示在隨機方塊中,運用二維陣列儲存、遍歷查詢等思想,在每次移動前迴圈查詢二維陣列相鄰的移動方向的行或列可以合併與否,如果沒有可以合併的數字方塊同時又沒有空餘的空間產生新的數字則遊戲宣告結束,同時,當檢測到合併的結果中出現2048,也宣告遊戲結束。
遊戲設計了非常簡單的互動邏輯,流程如下:
為了增加遊戲的使用者體驗,後期加入了操作音效(音效檔案提取自百度移動應用商店——2048),在移動和合並方塊時播放不同聲音。
二、 具體程式碼及程式框圖分析
整個遊戲有三個類,分別為遊戲的主類Game.class、事件處理類MyListener.class、聲音處理類PlaySound.class,下面對Game.class和MyListener.class進行說明。
Game.class的簡單程式框圖如下:
遊戲的主類Game.class是窗體程式JFrame的擴充套件類,主要負責介面的搭建,完成介面繪圖的工作。該類作為主類,主方法public static void main(String[] args)中先新建一個該類的物件,接著呼叫用與建立介面控制元件的方法IntUI(),程式碼如下:
public static void main(String[] args) {
Game UI = new Game();
UI.IntUI();
}
IntUI()方法用於JFrame控制元件及介面框架的搭建,程式碼解析如下:
首先建立一個窗體,標題為“2048小遊戲”,把座標固定在螢幕的x=450,y=100的位置,把窗體大小設定為寬400畫素高500畫素,然後把JPlane的佈局管理器設定為空,具體程式碼如下:
this.setTitle("2048小遊戲");
this.setLocation(450, 100);
this.setSize(400, 500 );
this.setLayout(null);
接下來分別是【新遊戲】、【幫助】、和【退一步】的按鈕,以【新遊戲】按鈕為例,建立一個新遊戲的圖片按鈕,圖片相對路徑為res/start.png,為了達到更美觀的顯示效果,把聚焦,邊線等特徵設定為false,把相對窗體的座標設定為(5, 10),大小設定為寬120畫素高30畫素,具體程式碼如下:
ImageIcon imgicon = new ImageIcon("res/start.png");
JButton bt = new JButton(imgicon);
bt.setFocusable(false);
bt.setBorderPainted(false);
bt.setFocusPainted(false);
bt.setContentAreaFilled(false);
bt.setBounds(-15, 10, 120, 30);
this.add(bt);
而分數顯示控制元件與按鈕控制元件類似,不再贅述。
佈置好控制元件後,為了徹底結束程序,不再佔用記憶體,使用 System exit 方法退出應用程式,同時由於按鈕都是相對窗體固定座標的,所以不允許使用者隨意改變窗體大小,最後把介面設定為可見,具體程式碼如下:
this.setDefaultCloseOperation(3);
this.setResizable(false);
this.setVisible(true);
對於按鈕的監聽,是在MyListener.class中處理的,在IntUI中只是新建一個物件來引用該類,該類具體後面會有說明,這裡引用的程式碼如下:
MyListener cl = new MyListener(this, Numbers, lb, bt, about,back);
bt.addActionListener(cl);
about.addActionListener(cl);
back.addActionListener(cl);
this.addKeyListener(cl);
IntUI方法至此結束。
接下來便是遊戲中大方框和數字小方框的繪製,這裡用到了paint方法來繪製容器。
paint方法不需要呼叫,只需要重寫。
在繼承父類的方法後,先繪製出大矩形框,為了美觀,這裡繪製圓角矩形框,底色的16進位制值為:0xBBADA0,邊長為370畫素,在視窗的相對位置為x=15畫素,y=110畫素,上下弧度為15,程式碼如下:
super.paint(g);
g.setColor(new Color(0xBBADA0));
g.fillRoundRect(15, 110, 370, 370, 15, 15);
接下來,通過雙重迴圈,繪製4*4的小方框,關於小方框的繪製的相對位置這裡有個關鍵的演算法,因為每一個小方框都要距離邊框及相鄰邊框的大小相等才能達到相對美觀的效果,以水平方向為例,分析如下:
因為總的寬度為370畫素,假如讓每個小方框的間距為10畫素,那麼在370畫素裡面除去5個小方框距離的畫素值10*5=50畫素外,還剩下320畫素,一行4個方框,正好80畫素一個小方框。在寫這雙重迴圈前,可以先繪製了一個簡單的位置圖如下:
以第一行的橫座標為例,每繪製完一個小方框,繪製下一個小方框時就要加上前一個方框的寬度,再加上邊框之前的距離,所以繪製4*4顏色為0xCDC1B4的小邊框的雙重迴圈的程式碼如下:
g.setColor(new Color(0xCDC1B4));
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
g.fillRoundRect(25 + i * 90, 120 + j * 90, 80, 80, 15, 15);
}
}
由於2048遊戲裡面可能顯示的數值有2、4、8、16等等不同的數值,同樣是為了美觀考慮,我們給顯示不同數值的方框繪製不同的顏色,同樣,由於一位數字2與兩位數字16甚至多位數字128或1024等來說,如果顯示的位置與大小相同,那麼2等一位數字的顯示是完美的,但是2048這些數字的顯示就會超出小方框,影響觀感所以還要對數字的相對位置和大小做一定的調整,這裡的具體程式碼如下:
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if (Numbers[j][i] != 0) {
int FontSize = 30;
int MoveX = 0, MoveY = 0;
switch (Numbers[j][i]) {
case 2:
g.setColor(new Color(0xeee4da));
FontSize = 30;
MoveX = 0;
MoveY = 0;
break;
case 4:
g.setColor(new Color(0xede0c8));
FontSize = 30;
MoveX = 0;
MoveY = 0;
break;
case 8:
g.setColor(new Color(0xf2b179));
FontSize = 30;
MoveX = 0;
MoveY = 0;
break;
case 16:
g.setColor(new Color(0xf59563));
FontSize = 29;
MoveX = -5;
MoveY = 0;
break;
case 32:
g.setColor(new Color(0xf67c5f));
FontSize = 29;
MoveX = -5;
MoveY = 0;
break;
case 64:
g.setColor(new Color(0xf65e3b));
FontSize = 29;
MoveX = -5;
MoveY = 0;
break;
case 128:
g.setColor(new Color(0xedcf72));
FontSize = 28;
MoveX = -10;
MoveY = 0;
break;
case 256:
g.setColor(new Color(0xedcc61));
FontSize = 28;
MoveX = -10;
MoveY = 0;
break;
case 512:
g.setColor(new Color(0xedc850));
FontSize = 28;
MoveX = -10;
MoveY = 0;
break;
case 1024:
g.setColor(new Color(0xedc53f));
FontSize = 27;
MoveX = -15;
MoveY = 0;
break;
case 2048:
g.setColor(new Color(0xedc22e));
FontSize = 27;
MoveX = -15;
MoveY = 0;
break;
default:
g.setColor(new Color(0x000000));
break;
}
g.fillRoundRect(25 + i * 90, 120 + j * 90, 80, 80, 10, 10);
g.setColor(new Color(0x000000));
g.setFont(new Font("Arial", Font.PLAIN, FontSize));
g.drawString(Numbers[j][i] + "", 25 + i * 90 + 30 + MoveX,
120 + j * 90 + 50 + MoveY);
}
}
}
至此,Game.class的分析結束,下面是對按鈕監聽及時間處理的MyListener.class的分析。
MyListener.class是一個鍵盤監聽事件的擴充套件類,該類的簡單程式框圖如下:
在該類的開始,先宣告一些變數,如用於接收傳遞進來的陣列Numbers[][],隨機數rand,備份陣列用的BackUp[][],BackUp2[][]等等,程式碼如下:
private Game UI;
private int Numbers[][];
private Random rand = new Random();
private int BackUp[][]= new int[4][4];
private int BackUp2[][]= new int[4][4];
public JLabel lb;
int score = 0;
int tempscore,tempscore2;
public JButton bt,about,back;
private boolean isWin=false,relive=false,hasBack=false,isSound=true;
然後是MyListener的構造方法,把來自Game類中傳進來的引數接收,程式碼如下:
public MyListener(Game UI, int Numbers[][], JLabel lb,JButton bt,JButton about,JButton back,JCheckBox isSoundBox) {
this.UI = UI;
this.Numbers = Numbers;
this.lb = lb;
this.bt=bt;
this.about=about;
this.back=back;
this.isSoundBox=isSoundBox;
}
接下來是按鈕的監聽,通過e.getSource()取得按鈕相應的值,進行相應的處理,下面分別解析下【新遊戲】按鈕和【退一步】按鈕的程式碼:
【新遊戲】按鈕作為遊戲的開始,將會在按下該按鈕是初始化很多資料,例如遊戲勝利標誌,分數值,4*4的二維陣列等,該按鈕的控制程式碼的工作邏輯是這樣的:先把勝利標誌置為false,然後通過雙重迴圈,把所有的小方格的資料初始化為0,接著把分數值置為0,接下來分別定義四個4以內的隨機數整型變數,兩兩為行數和列數,當它們組合相等時再重新產生兩個新的,直到不相等為止,然後生成2個2或者4的數字賦值到對應的位置,完成賦值後,重寫一次paint函式,把陣列的非零元素繪製在對應位置的小方框內,程式碼如下:
if(e.getSource() ==bt ){
isWin=false;
for (int i = 0; i < 4; i++)
for (int j = 0; j < 4; j++)
Numbers[i][j] = 0;
score = 0;
lb.setText("分數:" + score);
int r1 = rand.nextInt(4);
int r2 = rand.nextInt(4);
int c1 = rand.nextInt(4);
int c2 = rand.nextInt(4);
while (r1 == r2 && c1 == c2) {
r2 = rand.nextInt(4);
c2 = rand.nextInt(4);
}
int value1 = rand.nextInt(2) * 2 + 2;
int value2 = rand.nextInt(2) * 2 + 2;
Numbers[r1][c1] = value1;
Numbers[r2][c2] = value2;
UI.paint(UI.getGraphics());
}
【退一步】按鈕分了兩種情況考慮,為了出現退一步繼續按【退一步】按鈕出現異常情況,加了一個是否已經進行過一次回退操作了的標誌hasBack,進入回退按鈕的操作後,先判斷是否是起死回生型別的回退,如果不是起死回生型別的回退則把非起死回生分數備份的變數tempscore賦值回記錄分數的變數score,然後迴圈呼叫java.util.Arrays.copyOf()方法複製陣列,把備份的陣列複製回去Numbers陣列;如果是起死回生型別的回退,重新複製等操作和前面是一樣的,不同點在於起死回生型別的回退在操作後,把起死回生回退的標誌relive重新置為false。在完成這些操作後,重新繪圖,程式碼如下:
else if(e.getSource()==back&&hasBack==false){
hasBack=true;
if(relive==false){
score=tempscore;
lb.setText("分數:" + score);
for(int i=0;i<BackUp.length;i++){
Numbers[i]=Arrays.copyOf(BackUp[i], BackUp[i].length);
}
}
else{
score=tempscore2;
lb.setText("分數:" + score);
for(int i=0;i<BackUp2.length;i++){
Numbers[i]=Arrays.copyOf(BackUp2[i], BackUp2[i].length);
}
relive=false;
}
UI.paint(UI.getGraphics());
}
下面是按鍵監聽的解析,按鍵監聽通過相應的鍵值識別按鍵,然後運用switch開關語句控制不同按鍵的事件。在處理所有時間之前,先定義三個整型變數,用於計數,然後判斷BackUp陣列是否為空,因為第一次執行時用於普通陣列備份的BackUp陣列是沒有任何東西的,直接拿來備份會報異常。在備份好陣列後,就是對應的鍵值的監聽,以按下方向鍵左為例(其它三個方向鍵處理方式基本相同,只是行列及方向的區別),向左移動時,採用三個雙重迴圈,三個迴圈均為外迴圈的變數h控制行數,內迴圈變數l控制列數。在第一個雙重迴圈中,內迴圈先遍歷每一列,判斷該列的前一列是否為0,如果前一列為0,則把該列賦值給一個臨時變數,把臨時變數的值賦值給前一列,然後把該列置0;外迴圈是遍歷行數,每完成一行的操作轉至下一行繼續重複操作。該操作的程式碼如下:
for (int h = 0; h < 4; h++)
for (int l = 0; l < 4; l++)
if (Numbers[h][l] != 0) {
int temp = Numbers[h][l];
int pre = l - 1;
while (pre >=0 && Numbers[h][pre] == 0) {
Numbers[h][pre] = temp;
Numbers[h][pre + 1] = 0;
pre--;
Counter++;
}
}
在完成一次靠左移動後,接下來的雙重迴圈使用來合併小方格中數字相同的元素的,把他們相加並放到靠左的方格中,同樣外迴圈是用來控制行數,內迴圈控制列數,在內迴圈中加了一個判斷條件,當列數加1小於4時(因為只需迴圈到第三列即可完成該操作),同時該列與該列+1的那一列的元素值相等且它們都不等於0時,即可進行合併操作,合併後,把該列的值置為兩元素相加的結果,並把該列+1列置為0,然後把統計是否移動了的計數器進行一個自增操作。完成該操作的程式碼如下:
for(int h=0;h<4;h++)
for(int l=0;l<4;l++)
if(l+1<4&&(Numbers[h][l]==Numbers[h][l+1])&&(Numbers[h][l]!=0||Numbers[h][l+1]!=0)){
new PlaySound("merge.wav").start();
Numbers[h][l]=Numbers[h][l]+Numbers[h][l+1];
Numbers[h][l+1]=0;
Counter++;
score+=Numbers[h][l];
if(Numbers[h][l]==2048){
isWin=true;
}
}
在完成該操作後,為了避免合併後,出現方塊不在最左邊的情況,還需要一次整體遍歷左移,這次整體遍歷左移和第一次的雙重迴圈一樣,所以這裡不再分析。
在完成移動及合併操作後,要判斷相鄰的方塊是否還能合併,因為有可能出現移動後,相鄰的方塊又能進行新的合併,如果不加這個判斷的話,當所有的方塊全部填滿時會無判斷遊戲結束。
這裡判斷相鄰的新的方塊是否能進一步合併也是用到雙重迴圈,外迴圈控制行數,內迴圈控制列數,逐個判斷下和右是否能進一步合併,如果能則相鄰方塊的計數器做自增操作,程式碼如下:
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (Numbers[i][j] == Numbers[i][j + 1]&& Numbers[i][j] != 0) {
NumNearCounter++;
}
if (Numbers[i][j] == Numbers[i + 1][j]&& Numbers[i][j] != 0) {
NumNearCounter++;
}
if (Numbers[3][j] == Numbers[3][j + 1]&& Numbers[3][j] != 0) {
NumNearCounter++;
}
if (Numbers[i][3] == Numbers[i + 1][3]&& Numbers[i][3] != 0) {
NumNearCounter++;
}
}
}
在完成上述操作後,在判斷非0元素的個數,應為當非零元素個數為0時,即代表所有的小方塊中已經有元素存在了,16個方塊全部都滿了,這裡的程式碼如下:
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if (Numbers[i][j] != 0) {
NumCounter++;
}
}
}
在完成統計空格個數後,如果按下按鍵後發生了移動,就完成分數的更新,因為要移動一次後在隨機的空的格子(這裡指元素值為0的格子)裡面產生新的基於2或者4的隨機數,程式碼如下:
if (Counter > 0) {
lb.setText("分數:" + score);
int r1 = rand.nextInt(4);
int c1 = rand.nextInt(4);
while (Numbers[r1][c1] != 0) {
r1 = rand.nextInt(4);
c1 = rand.nextInt(4);
}
int value1 = rand.nextInt(2) * 2 + 2;
Numbers[r1][c1] = value1;
}
接下來判斷這次移動後,有沒有出現isWin == true的情況,如果出現了,就彈出遊戲勝利的標誌,程式碼如下:
if (isWin == true){
UI.paint(UI.getGraphics());
JOptionPane.showMessageDialog(UI, "恭喜你贏了!\n您的最終得分為:" + score);
}
然後,判斷是否是16個格子全滿了同時相鄰的方格不能進一步合併了,如果是則把可以進行起死回生操作的標誌relive置為true,同時彈出遊戲結束的提示語,程式碼如下:
if (NumCounter == 16 && NumNearCounter == 0) {
relive = true;
JOptionPane.showMessageDialog(UI, "沒地方可以合併咯!!"
+ "\n很遺憾,您輸了~>_<~" + "\n悄悄告訴你,遊戲有起死回生功能哦,不信你“退一步”試試?"
+ "\n說不定能扭轉乾坤捏 (^_~)");
}
最後,重新繪製圖形。
MyListener類的分析到此結束。
三、 參考資料
Java API1.6.0中文版
Java從入門到精通(第三版)