第八章 地圖的設計和實現
這本來是第十章,前面計劃還有兩章的內容,一是跟第四章一樣,完成一個Asteroid遊戲作為小結,總結一下前面講過的Sprite的用法,並示範NPC和子彈的處理方法。但是,在寫第七章的最後一個例子的時候,把本來簡單的碰撞檢測的例子擴充了一下,加入了NPC和子彈,基本和Asteroid的功能差不多了,所以就把原定的Asteroid砍掉了。另外一章是講解程式的生命週期,內容相對簡單,但考慮到上一章講Sprite時已經引入了TiledLayer,如果接著講地圖應該會更連貫些,所以把生命週期向後移了一章。
那麼,就先讓我們看看地圖的設計和實現。
如果我們需要的地圖很小又很少,完全可以將整個地圖畫在一張圖片上。但是如果地圖很多,繪製和管理地圖的工作就會很麻煩,這時我們就需要用到另外一種技術——圖塊(Tile)。所謂Tile,就是將地圖中的公用元素提取出來,然後在顯示的時候組合這些元素形成完整的地圖,這就是本章要介紹的主要內容。
如是組成坦克大戰地圖的所有元素(16x16像素):
接下來讓我們看一幅遊戲中的情境:
可以看到,整個遊戲情境就是由上面那些Tile構成的。這樣,擺在我們面前的任務就很簡單了:將地圖依照Tile的大小分成若干格,將對應的Tile填到格子中。
在前面講Sprite的時候,我們知道可以將組合在一張圖片中的關鍵楨編號,以後就可以通過編號來使用這個楨(參看上一章楨動畫的相關內容),這種方法也同樣適用於Tile。而2D地圖很容易讓我們想到二維數組。也就是說我們可以將Tile的編號放到二維數組中,這樣我們就可以通過曆遍數組元素,像顯示Sprite那樣將整張地圖一塊一塊的顯示出來。
以上面的那張遊戲為例,一共13行13列,我們可以定義一個13x13的二維數組(因為地圖上有空白地區,所以我們將Tile的編號從1開始,用0表示空白)。
讓我們找到GameView_Old.java,增加成員變數map[][]:
int[][] map = {
{0,0,0,2,0,0,0,2,0,0,0,0,0},
{0,1,0,2,0,0,0,1,0,1,0,1,0},
{0,1,0,0,0,0,1,1,0,1,2,1,0},
{0,0,0,1,0,0,0,0,0,2,0,0,0},
{3,0,0,1,0,0,2,0,0,1,3,1,2},
{3,3,0,0,0,1,0,0,2,0,3,0,0},
{0,1,1,1,3,3,3,2,0,0,3,1,0},
{0,0,0,2,3,1,0,1,0,1,0,1,0},
{2,1,0,2,0,1,0,1,0,0,0,1,0},
{0,1,0,1,0,1,1,1,0,1,2,1,0},
{0,1,0,1,0,1,1,1,0,0,0,0,0},
{0,1,0,0,0,1,1,1,0,1,0,1,0},
{0,1,0,1,0,1,6,1,0,1,1,1,0},
};
在資源中增加tile.png
在建構函式中初始化bitmap對象
res = context.getResources();
bmp = BitmapFactory.decodeResource(res, R.drawable.tile);
在onDraw中繪圖
//用來顯示圖塊的Rect對象
Rect src = new Rect(0, 0, 0, 16);
Rect dst = new Rect();
for(int i=0; i
for(int j=0; j
//根據Tile的編號得到對應的位置
src.left = (map[i][j]-1) * 16;
src.right = src.left + 16;
//根據地圖上的編號計算對應的螢幕位置
dst.left = j * 16;
dst.right = dst.left + 16;
dst.top = i * 16;
dst.bottom = dst.top + 16;
canvas.drawBitmap(bmp, src, dst, paint);
}
}
最後不要忘了修改Main.java中的setContentView
GameView_Old gameView;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
gameView = new GameView_Old(this);
setContentView(gameView);
}
好了,運行一下程式看看結果:
對比一下原圖,除了基地四周的磚塊之外兩者並無二致,可以說我們的Tile地圖實踐基本成功。
現在我們知道了使用Tile顯示地圖的原理,實際上,我們不需要每次都很麻煩的寫那麼多代碼,還記得前面說過的TiledLayer嗎?其中早已封裝了上述操作。不僅如此,TiledLayer還能顯示動態地圖呢。下面就讓我們看一看TiledLayer的基本用法。其實,它與Sprite的用法非常相似(我們需要將TiledLayer.java加入到項目中,請使用本章附帶程式的TiledLayer.java檔案,上一章的TiledLayer並不能正確工作):
這次讓我們使用GameView,把剛剛的數組map拷貝到GameView中,並聲明一個TiledLayer類型變數
//背景
TiledLayer backGround;
在建構函式中初始化TiledLayer
// 背景圖
backGround = new TiledLayer(13, 13, BitmapFactory.decodeResource(res,
R.drawable.tile), 16, 16);
TiledLayer的建構函式有5個參數,分別是地圖的行列數(是以Tile為單位的),包含Tile的bitmap對象,Tile的寬度和高度。
通過setCell方法將定義在數組中的Tile編號傳遞給TiledLayer。
for(int i=0; i
for(int j=0; j
backGround.setCell(i, j, map[i][j]);
}
}
最後,只需要在run函數中調用paint方法,就可以將TiledLayer顯示出來了。當然,你可以像控制Sprite那樣控制TiledLayer顯示的位置。
backGround.paint(c);
讓我們看一下啟動並執行效果
下面讓我們來學習如何?動態地圖。所謂動態地圖跟前面講到的楨動畫是一個道理,就是迴圈顯示幾個關鍵楨。讓我們看一下前面Tiles的圖片
我們會發現有兩張水域的圖片,這就是為動態地圖準備的,組合起來之後應該會有如下的效果:
那麼,我們如何在TiledLayer中實現動態地圖呢?TiledLayer為我們準備了這樣幾個函數:
createAnimatedTile:建立動態圖塊。很多人會迷惑於這個函數的名字,說是建立動態圖塊,可是建立在哪兒啊?建立出來怎麼用呢?只有天知道。其實,這個函數的主要功能也就是為動態圖塊分配了一個儲存結構。你不調用它還會報錯,調用了其實也沒什麼用。createAnimatedTile返回一個動態圖塊的編號,從-1開始依次遞減,第一次調用返回-1,這樣就分配了一個編號為-1的動態圖塊。第二次調用會返回-2,依次類推。這個傳回值一般沒有用,因為我們做地圖的時候肯定已經確定了動態圖塊的位置,這個編號早就寫到了數組中了。以後你就可以通過這個編號來控制相應的圖塊。函數有一個參數,指定一個Tile的編號,動態圖塊最初顯示的就是這個Tile。而正是通過改變這個Tile來實現動畫的。請看下面代碼:
backGround.createAnimatedTile(4);
我們初始化了一個動態圖塊,編號是-1,參數4表示當前顯示Tile順序圖表中的第四個Tile,就是第一張水域的圖片。
setAnimatedTile:動態圖塊的內容就是使用這個函數改變的。函數的第一個參數是動態圖塊的編號,如剛剛的-1。第二個參數是Tile的編號。
說到這裡,大家應該清楚動態地圖的用法了吧:
首先在地圖數組中確定需要顯示動態圖塊的位置,填入相應的編號,例如我們將上一張地圖的第三行增加三塊水域
int[][] map = {
{0,0,0,2,0,0,0,2,0,0,0,0,0},
{0,1,0,2,0,0,0,1,0,1,0,1,0},
{0,1,-1,-1,-1,0,1,1,0,1,2,1,0},
{0,0,0,1,0,0,0,0,0,2,0,0,0},
{3,0,0,1,0,0,2,0,0,1,3,1,2},
{3,3,0,0,0,1,0,0,2,0,3,0,0},
{0,1,1,1,3,3,3,2,0,0,3,1,0},
{0,0,0,2,3,1,0,1,0,1,0,1,0},
{2,1,0,2,0,1,0,1,0,0,0,1,0},
{0,1,0,1,0,1,1,1,0,1,2,1,0},
{0,1,0,1,0,1,1,1,0,0,0,0,0},
{0,1,0,0,0,1,1,1,0,1,0,1,0},
{0,1,0,1,0,1,6,1,0,1,1,1,0},
};
然後在GameView的建構函式中初始化TiledLayer,除了在setCell之前調用createAnimatedTile之外,沒有其他區別。
最後就是在run函數中調用setAnimatedTile,不斷地改變圖塊了
if(backGround.getAnimatedTile(-1) == 4) {
backGround.setAnimatedTile(-1, 5);
} else {
backGround.setAnimatedTile(-1, 4);
}
backGround.paint(c);
來讓我們看一下啟動並執行效果
其實筆者覺得TiledLayer完全也可以像Sprite那樣使用楨序列和nextFrame來實現動態效果,似乎更易用一些,有興趣的讀者可以自己修改TiledLayer實現這個功能。
到這裡,我們已經掌握了顯示地圖的方法,但是,這個地圖還不能真正運用到我們的遊戲中。讀者肯定也看到了,在TiledLayer的第一個例子中,我們的坦克可以穿牆而過,顯然,這個地圖還缺少最基本的功能——阻擋。
有一種簡單的方案可以實現阻擋,讓我們看一下Sprite類,其中有一個方法:
public final boolean collidesWith(TiledLayer t, boolean pixelLevel)
檢測Sprite與TiledLayer的碰撞。這種檢測是以Tile為單位的,當與Sprite重合的Tiles編號不為0時函數返回true,否則返回false。
下面讓我們做一個小例子測試一下:
開啟GameView_Old,增加一個Sprite類型的成員變數
// 主角
Sprite player;
在建構函式中初始化player
// 初始化主角
player = new Sprite(BitmapFactory.decodeResource(res,
R.drawable.player1), 16, 16);
player.setFrameSequence(new int[] { 0, 1 });
在onDraw中繪製player
player.paint(c);
backGround.paint(c);
在onKeyDown中控制Sprite的運動
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
// TODO Auto-generated method stub
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
x = player.getX();
y = player.getY();
player.move(0, -16);
if(!player.collidesWith(backGround, false)) {
y -= 16;
}
player.setTransform(Sprite.TRANS_NONE);
player.setPosition(x, y);
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
x = player.getX();
y = player.getY();
player.move(0, 16);
if(!player.collidesWith(backGround, false)) {
y += 16;
}
player.setTransform(Sprite.TRANS_ROT180);
player.setPosition(x, y);
break;
case KeyEvent.KEYCODE_DPAD_LEFT:
x = player.getX();
y = player.getY();
player.move(-16, 0);
if(!player.collidesWith(backGround, false)) {
x -= 16;
}
player.setTransform(Sprite.TRANS_ROT270);
player.setPosition(x, y);
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
x = player.getX();
y = player.getY();
player.move(16, 0);
if(!player.collidesWith(backGround, false)) {
x += 16;
}
player.setTransform(Sprite.TRANS_ROT90);
player.setPosition(x, y);
break;
}
postInvalidate(); // 通知系統重新整理螢幕
return super.onKeyDown(keyCode, event);
}
可以看到,坦克只能在空白地區運動,這回不能上牆了。
但是這種方法還是比較粗糙的,很多時候不能實現我們的目的,比如坦克大戰中,水和磚頭是坦克不能通過的,但是掩體是可以通過的,還有子彈可以通過水域,這時候還是檢測Tile的編號來的準確些。就是說,我們事先確定好那些編號的Tile可以通過,哪些不能。然後模仿collidesWith方法根據Tank的位置取得它下一步要到達的Tile的編號,並判斷坦克是否被阻擋。
讓我們用這個方案重寫onKeyDown方法(為了簡化教程,我們假設每次player和tile都是完全重合的):
首先我們先定義一個函數用來判斷Tank是否可以通過
// 判斷坦克是否可以通過
private boolean tankPass(int x, int y) {
// 不超過地圖範圍
if (x 12 * 16 || y 12 * 16) {
return false;
}
int tid = map[y / 16][x / 16];
if (tid == 1 || tid == 2 || tid == -1)
return false;
return true;
}
然後在onKeyDown中運用新的方案
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
// TODO Auto-generated method stub
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
x = player.getX();
y = player.getY();
player.move(0, -16);
if (tankPass(player.getX(), player.getY())) {
y -= 16;
}
player.setTransform(Sprite.TRANS_NONE);
player.setPosition(x, y);
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
x = player.getX();
y = player.getY();
player.move(0, 16);
if (tankPass(player.getX(), player.getY())) {
y += 16;
}
player.setTransform(Sprite.TRANS_ROT180);
player.setPosition(x, y);
break;
case KeyEvent.KEYCODE_DPAD_LEFT:
x = player.getX();
y = player.getY();
player.move(-16, 0);
if (tankPass(player.getX(), player.getY())) {
x -= 16;
}
player.setTransform(Sprite.TRANS_ROT270);
player.setPosition(x, y);
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
x = player.getX();
y = player.getY();
player.move(16, 0);
if (tankPass(player.getX(), player.getY())) {
x += 16;
}
player.setTransform(Sprite.TRANS_ROT90);
player.setPosition(x, y);
break;
}
postInvalidate(); // 通知系統重新整理螢幕
return super.onKeyDown(keyCode, event);
}
現在讓我們運行一下看看吧,這回終於能夠達到了我們想要的效果,我們的坦克正藏在掩體下面,並且它不能通過牆和水域。
到此為止,關於地圖的內容就講解完畢了。這些內容並不複雜,首先介紹了使用Tile組成地圖的原理,然後介紹了TiledLayer已經動態地圖,最後示範了阻擋的實現方法。本章的大部分例子使用了GameView_Old,並沒有使用遊戲迴圈,也沒有涉及到地圖的平滑滾動,在以後需要的時候會補充這部分知識。
本章樣本程式http://u.115.com/file/f12827d8db