第八章 地图的设计和实现
这本来是第十章,前面计划还有两章的内容,一是跟第四章一样,完成一个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<13; i++) {
for(int j=0; j<13; 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<13; i++) {
for(int j=0; j<13; 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 < 0 || x > 12 * 16 || y < 0 || 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,并没有使用游戏循环,也没有涉及到地图的平滑滚动,在以后需要的时候会补充这部分知识。