Android實例剖析筆記(七)
Snake這個項目把主界面剖成界面UI和游戲邏輯兩層,最基礎的界面UI部分用父類TileView來表示,子類SnakeView是在TileView的UI基礎上,加入相應的游戲控制邏輯,從而實現了兩者的分離,這對于游戲的修改非常有用。
UI實現部分
首先來看界面UI部分,基本思想大家都非常清楚:把整個屏幕看做一個二維數組,每一個元素可以視為一個方塊,因此每個方格在游戲進行過程中可以處于不同的狀態,比如空閑,墻,蘋果,貪食蛇(蛇身或蛇頭)。我們在操作游戲的過程,其實就是不斷修改相應方格的狀態,然后再讓整個View去重繪制自身(當然,還需要加入一些游戲當前所處狀態(失敗或成功)的判定機制)。TileView的數據成員如下:
protected static int mTileSize;
//方格的行數和列數
protected static int mXTileCount;
protected static int mYTileCount;
//xy坐標系的偏移量
private static int mXOffset;
private static int mYOffset;
//存儲三種方格的圖標文件
private Bitmap[] mTileArray;
//二維方格地圖
private int[][] mTileGrid;
那么在游戲還未正式開始前,首先要做一些初始化工作,在View第一次加載時會首先調用onSizeChanged,這里就是做這些事的最好時機。
protected void onSizeChanged(int w, int h, int oldw, int oldh)
{
//計算屏幕中可放置的方格的行數和列數
mXTileCount = (int) Math.floor(w / mTileSize);
mYTileCount = (int) Math.floor(h / mTileSize);
mXOffset = ((w - (mTileSize * mXTileCount)) / 2);
mYOffset = ((h - (mTileSize * mYTileCount)) / 2);
mTileGrid = new int[mXTileCount][mYTileCount];
clearTiles();
}
注意模擬器屏幕默認的像素是320×400,而代碼中默認的方格大小為12,因此屏幕上放置的方格數為26×40,把屏幕剖分成這么大后,再設置一個相應的二維int型數組來記錄每一個方格的狀態,根據方格的狀態,可以從mTileArray保存的圖標文件中讀取對應的狀態圖標。
第一次調用完onSizeChanged后,會緊跟著第一次來調用onDraw來繪制View自身,當然,此時由于所有方格的狀態都是0,所以它在屏幕上等于什么也不會去繪制。
{
super.onDraw(canvas);
for (int x = 0; x < mXTileCount; x += 1)
{
for (int y = 0; y < mYTileCount; y += 1)
{
if (mTileGrid[x][y] > 0)
{
canvas.drawBitmap(mTileArray[mTileGrid[x][y]],
mXOffset + x * mTileSize,
mYOffset + y * mTileSize,
mPaint);
}
}
}
}
onDraw要做的工作非常簡單,就是掃描每一個方格,根據方格當前狀態,從圖標文件中選擇對應的圖標繪制到這個方格上。當然這個onDraw在游戲進行過程中,會不斷地被調用,從而界面不斷被更新。
游戲邏輯部分
再來看子類SnakeView是如何在父類TileView的基礎上,加入特定的游戲邏輯,從而完成Snake這個程序的。
private ArrayList<Coordinate> mAppleList = new ArrayList<Coordinate>();//蘋果方格列表
由于SnakeView從TileView繼承而來,則可以說它已經擁有這個二維方格地圖了(只是此時地圖里的所有方格狀態都是0)。那么它有了這么一個二維方格地圖,如何去初始化這個地圖呢?這在initNewGame函數中實現。
{
//清空蛇和蘋果占據的方格
mSnakeTrail.clear();
mAppleList.clear();
//目前組成蛇的方格式固定的,而且方向也固定朝北
mSnakeTrail.add(new Coordinate(7, 7));
mSnakeTrail.add(new Coordinate(6, 7));
mSnakeTrail.add(new Coordinate(5, 7));
mSnakeTrail.add(new Coordinate(4, 7));
mSnakeTrail.add(new Coordinate(3, 7));
mSnakeTrail.add(new Coordinate(2, 7));
mNextDirection = NORTH;
//隨即加入蘋果
for (int i = 0; i < nApples; ++i)
{
addRandomApple();
}
//初始化運動速率和玩家成績
mMoveDelay = 600;
mScore = 0;
}
想象下對整個游戲屏幕拍張照,然后對其下一個狀態再拍張照,那么兩張照片之間的區別是怎么產生的呢?對于系統來說,它只知道不斷調用onDraw,后者負責對整個屏幕進行繪制,那要產生兩個屏幕之間的差異,肯定要通過一些手段對某些數據結構(比如這里的二維方格地圖)進行調整(比如用戶的控制指令,定時器等),然后等到下一次onDraw時就會把這些更改在界面上反映出來。
這里要著重說明下private long mMoveDelay = 600;這個成員變量,雖然很不起眼,但仔細考慮它的作用就會發現很有趣,那么改變它的大小到底是如何讓我們感覺到游戲變快或變慢呢?
可以打個簡單的比方,在時刻0游戲啟動,首先把蛇和蘋果的位置都在方格地圖上作好了標記,然后我們在update函數中修改蛇身讓蛇向北前進一步,而這個改變此時還只是停留在內部的核心數據結構上(即二維方格地圖),還沒有在界面上顯示出來。當然,我們馬上想到要想讓這更改顯示出來,讓系統調用onDraw去繪制不就完了嗎?可是問題是我們不知道系統是隔多長時間去調用onDraw函數,于是mMoveDelay此時就發揮作用了,通過它就可以設置休眠的時間,等時間一到,馬上就會通知SnakeView去重繪制。你可以試試把mMoveDelay數值調大,就會看出我上面提到的“拍照“的效果。
Handler的使用
寫過JavaScript或者ActionScript的開發者,對于setInterval的用法會非常了解。那么在Android中如何實現setInterval的方法呢?其中有兩種方法可以實現類似的功能,其中一個是在線程中調用Handler方法,另外一個是應用Timer。Snake中使用了前者
{
@Override
public void handleMessage(Message msg)
{//“蘇醒”后的處理
SnakeView.this.update();
SnakeView.this.invalidate();
}
public void sleep(long delayMillis)
{//休眠delayMillis毫秒
this.removeMessages(0);
sendMessageDelayed(obtainMessage(0), delayMillis);
}
};
而實際調用的處理函數update就可以說是整個游戲的引擎,正是由于它的工作(修改蛇和蘋果的狀態到一個新的狀態,然后休眠自己,然后等到蘇醒后在Handler中就會讓系統區繪制上次修改過的二維方塊地圖,然后再次調用update,如此循環反復,生生不息),才使得游戲不斷被推進,因此,比做“引擎“不為過。
{
if (mMode == RUNNING)
{
long now = System.currentTimeMillis();
if (now - mLastMove > mMoveDelay)
{
clearTiles();
updateWalls();
updateSnake();
updateApples();
mLastMove = now;
}
mRedrawHandler.sleep(mMoveDelay);
}
}
既然update是游戲的動力,要讓游戲停止下來只要不再調用update就可以了(因為此時其實是畫面靜止了),因此游戲進入暫停(這個狀態還可以轉為“運行“,其實就是繼續可以修改,再繪制),若進入失敗(其實此時二維方塊地圖還停留在最后一個畫面處,這也是為什么在開始時要首先清理掉整個地圖)【這一點,可以在游戲失敗后,再次開始新游戲,此時通過設置的斷點即可觀察到上次游戲運行時的底層數據】。
一點困惑
可是個人認為Snake下面這段代碼讀起來有點怪,有點像一個“先有雞,還是先有蛋?“的問題,導致我的思維邏輯上出現一個“怪圈“。
{
SnakeView.this.update();
SnakeView.this.invalidate();
}
按照這段代碼的意思來看,當休眠的時間已經到了,首先去調用update,即為下一次繪制做準備工作,再讓自己休眠起來,最后通知系統重繪制自己。
哎,這讓我難以理解,還是回到時刻0的例子來說,在時刻0時讓蛇身向北前進了一步(指的是底層的二維方格地圖的修改,不是界面),然后讓自己休眠0.6毫秒,當時間到了,首先去調用update方法,那么就又會讓蛇身做出修改,也就是把上一次還沒繪制的覆蓋掉了(那么上一次的修改豈不是白費,還沒畫上去呢),更何況在update中又會讓自己去休眠(還沒調用invalidate,怎么又去休眠了?),又怎么還能去通知系統調用我的onDraw方法呢?也就是說invalidate根本沒有執行???
按我的理解,應該把順序顛倒一下,先通知系統去調用onDraw方法重繪,使得上一次對底層二維方格地圖的修改顯示出來,然后再去為下一次修改做準備工作,最后讓自己進入休眠,等待蘇醒過來,如此循環反復。實驗證明,顛倒過來也是正確的,不過關于這一個迷惑我的地方,希望有朋友能指點我一下!
記得在javascript里使用setInterval時,也是先寫處理邏輯,然后在末尾處寫上一句setInterval(這也是我習慣的思維方式了),難道google上面這種寫法有何深意?
此外,感覺每次繪制時都重新繪制墻壁,有點浪費時間,因為墻壁根本沒有任何變化的。還有就是mLastMove這個變量設置的初衷是保證當前時間點距上一次變化已經過去了mMoveDelay毫秒,可是既然已經用了sleep機制,再使用這個時間差看上去并無必要。