经过前几章的学习,大家对使用位图、接受用户控制应该已经有了初步的概念,也可以运用这些知识完成简单的小游戏。这一章中,我们会为游戏中最重要的部分——图形处理建立一个基本的框架,这还不是游戏引擎,但是其中很多方法可以为读者以后创建自己的游戏引擎提供借鉴。这一章的涉及的内容比较多,既有2D游戏的基础理论,又有复杂的代码。尤其是代码部分,如果详细讲解,恐怕会占用很大的篇幅。所以我们只对关键的函数进行讲解,以方便读者今后灵活运用这些代码(所有的源代码都与本章节内容一同提供下载)。
这个框架是完全依照MIDP中javax.microedition.lcdui.game包设计的:
Classes
GameCanvas
Layer
LayerManager
Sprite
TiledLayer
game包中有5个类,其中Layer(层)是一个抽象类,对图形显示作了基本的定义。以我们的目标游戏《坦克大战》为例,在游戏中有这样一些图形元素:我方和敌方的坦克、坦克发出的子弹、地面、墙体、水域掩体等。这些元素虽然外观不同,但是本质上却非常相似:都是在特定位置以特定尺寸显示一个或一组位图,有些位图位置还会变动,Layer就定义了位置,尺寸,显示等相关的功能。之所以叫做Layer,与游戏中分层地图的概念有关,先让我们了解一下什么是分层地图:还是说坦克大战,当我们的坦克行驶在普通地面上时,坦克的图像肯定是覆盖了地面的图像,这样我们能看到坦克。当坦克行驶到掩体时,我们会发现,掩体的图像覆盖了坦克的图像,如图所示:
实际上,我们在程序中,只要首先显示地面的图像,然后显示坦克的图像,最后显示掩体的图像(掩体图片是镂空的),就能达到这种效果,这就是分层地图。通常我们把最下面的叫做地面层,中间的叫做物件层,最上面的叫做天空层。关于地图我们就讲这么多,这里只介绍图形意义上的分层,是为了帮助大家理解Layer一词的意义。关于地图的详细内容我们在第十章会深入讲解。
首先让我们看一下抽象类Layer的定义:
package org.yexing.android.games.common;
import android.graphics.Canvas;
public abstract class Layer {
int x = 0; // Layer的横坐标
int y = 0; // Layer的纵坐标
int width = 0; // Layer的宽度
int height = 0; // Layer的高度
boolean visible = true; // Layer是否可见
Layer(int width, int height) {
setWidthImpl(width);
setHeightImpl(height);
}
/**
* 设定Layer的显示位置
*
* @param x
* 横坐标
* @param y
* 纵坐标
*/
public void setPosition(int x, int y) {
this.x = x;
this.y = y;
}
/**
* 相对于当前的位置移动Layer
*
* @param dx
* 横坐标变化量
* @param dy
* 纵坐标变化量
*/
public void move(int dx, int dy) {
x += dx;
y += dy;
}
/**
* 取得Layer的横坐标
*
* @return 横坐标值
*/
public final int getX() {
return x;
}
/**
* 取得Layer的纵坐标
*
* @return 纵坐标值
*/
public final int getY() {
return y;
}
/**
* 取得Layer的宽度
*
* @return 宽度值
*/
public final int getWidth() {
return width;
}
/**
* 取得Layer的高度
*
* @return 高度值
*/
public final int getHeight() {
return height;
}
/**
* 设置Layer是否可见
*
* @param visible
* true Layer可见,false Layer不可见
*/
public void setVisible(boolean visible) {
this.visible = visible;
}
/**
* 检测Layer是否可见
*
* @return true Layer可见,false Layer不可见
*/
public final boolean isVisible() {
return visible;
}
/**
* 绘制Layer,必须被重载
*
* @param c
*/
public abstract void paint(Canvas c);
/**
* 设置Layer的宽度
*
* @param width
*/
void setWidthImpl(int width) {
if (width < 0) {
throw new IllegalArgumentException();
}
this.width = width;
}
/**
* 设置Layer的高度
*
* @param height
*/
void setHeightImpl(int height) {
if (height < 0) {
throw new IllegalArgumentException();
}
this.height = height;
}
}
Layer的代码不多,根据函数名称就可以知道它的功能,主要是Layer尺寸、位置的设定和获取。其中最重要的方法paint是虚方法,Layer图像就是通过这个方法显示出来的。因此继承自Layer的所有类都要实现这个方法。
Sprite(精灵)继承自Layer,同时又增加了帧动画,图形变换和碰撞检测的功能。Sprite是我们这一章重点介绍的内容。首先让我们了解一下精灵的概念。Sprite这个词在2D游戏中非常常见,一般指游戏中具有独立外观和属性的个体元素。如主角、NPC、宝箱、子弹等等,这些都是精灵。
下面就让我们来创建Sprite类并使其继承自Layer。创建完毕时,IDE会提示必须实现paint方法。但是这时候我们会发现,paint方法要显示那些图形呢?没有。因此我们需要为Sprite增加一个Bitmap类型变量,用来存放paint要显示的图形。同时,我们要创建一个构造函数用来初始化这个Bitmap变量。
public Sprite(Bitmap image) {
super(image.getWidth(), image.getHeight());
initializeFrames(image, image.getWidth(), image.getHeight(), false);
initCollisionRectBounds();
this.setTransformImpl(TRANS_NONE);
}
虽然这个构造函数只有几行,却涉及到不少的知识。super不用说了,initializeFrames是做什么的呢?这就要提到帧动画的概念了。什么是帧动画呢?如下图,我们看到一组星星的图片(4张16x16的位图)
当我们在同一个位置以一定的时间间隔连续显示这几幅图片的时候就变成了这个样子
我们看到,星星在发光,这就是帧动画。即取得一个连续画面中的几个关键帧,在一定的时间间隔下连续的显示这些帧从而形成动画,initializeFrames的功能就是初始化这些关键帧。那么又为什么要初始化呢?通常情况下,我们为了节省空间也为了便于管理,会将一组动画的多个帧保存在同一个图片文件中(如上图的星星)。这样一来每次显示的时候就不能显示整张图片,而只能显示这个图片的一部分。因此,我们要计算每一帧在整张图片上的位置以便正确显示。就让我们来看看initializeFrames的定义
private void initializeFrames(Bitmap image, int fWidth, int fHeight,
boolean maintainCurFrame)
initializeFrames作了这样的工作,首先取得一个位图,然后根据用户设置的单一帧的宽度和高度计算这个位图中包括多少帧。如刚刚我们看到的星星的图片(64x16像素),当单一帧的宽度和高度与图片相同的时候,就只有一帧。但是当一帧的宽度和高度均为16像素时,整个图片就可以分为4帧了。这时候,函数会计算每一帧的顶点坐标,如第一帧的顶点是(0,0),第二帧的顶点是(16,0),并依次类推。这个函数还可以处理更复杂的情况,如下图:
函数会将计算好的各个帧的顶点横纵坐标分别保存在两个数组中(frameCoordsX[],frameCoordsY[]),下次我们使用帧的序号访问各个帧时(在paint中)就可以很快找到它的所对应的位图区域了。
在这个Sprite的构造函数中,initializeFrames使用了image.getWidth()和image.getHeight()作为一帧的高度和宽度,所以这个精灵注定只能有一帧。Sprite的构造函数还有其他样式,如
public Sprite(Bitmap image, int frameWidth, int frameHeight)
这时候我们就可以指定帧的宽度和高度,定义具有多个帧的Sprite了。
说完了为帧动画做初始化工作的initializeFrames,让我们来看下一个函数initCollisionRectBounds。
private void initCollisionRectBounds() {
collisionRectX = 0;
collisionRectY = 0;
collisionRectWidth = this.width;
collisionRectHeight = this.height;
}
这个函数的代码不多,名字翻译过来就是“初始化碰撞矩形边缘”。由此引出另外一个游戏中的重要概念——碰撞检测。在我们的目标游戏坦克大战中,碰撞检测可是少不了的,我们的子弹击中敌方坦克就是一次碰撞,只有进行了碰撞检测才能够触发这次击中事件,不然我们就没法消灭敌人的坦克了。2D游戏中的碰撞检测有几种,最简单的是矩形碰撞检测,复杂一些的有多边形检测和像素检测等。这里我们只介绍一下矩形检测。如图:
我们取坦克和子弹的矩形外框,当这两个矩形重叠的时候就认为是碰撞了。函数initCollisionRectBounds的功能就是设定Sprite的矩形外框。同前面初始化帧的原理一样,我们需要这个函数在多个帧组合成的图片中确定一帧的大小。
现在我们还剩下构造函数中最后一行代码了:
this.setTransformImpl(TRANS_NONE);
这行代码设置了Sprite图形的变换方式。变换一共有8种,定义如下:
public static final int TRANS_NONE = 0;
public static final int TRANS_ROT90 = 5;
public static final int TRANS_ROT180 = 3;
public static final int TRANS_ROT270 = 6;
public static final int TRANS_MIRROR = 2;
public static final int TRANS_MIRROR_ROT90 = 7;
public static final int TRANS_MIRROR_ROT180 = 1;
public static final int TRANS_MIRROR_ROT270 = 4;
所谓变换,就是对图形进行旋转和镜像等操作,这就相当于增加了图形资源。如图:
因为要显示向4个方向行驶的坦克,每个坦克都需要4组图片。如果我们使用了旋转变换功能,每个坦克只需要一组图片就够了,其他的图片完全可以由旋转获得。需要指出的是,旋转是围绕参照点(reference pixel)进行的,如果没有使用函数setRefPixelPosition设定参照点,缺省情况下参照点就是(0,0)。因此如果我们使用参数TRANS_ROT90旋转的话,图形应该是这样
这时候有一点必须要特别提示一下,旋转之后,getX和getY的返回值将发生变化。
讲到这里,这一章的理论实在是够多了,我们必须要总结一下:
首先这一章讲的是图形显示。我们依照j2me中的games包建立了两个类Layer和Sprite。重点介绍了Sprite(精灵)类相关的知识,包括桢动画、碰撞检测和旋转变换。下面我们来看一组Sprite的应用实例:
第一个例子,在屏幕上显示一个带有桢动画的Sprite。
让我们拿出前面做过的Tank的源代码,将Layer.java和Sprite.java添加到源代码中。
(其中的TiledLayer我们在讲解地图的时候再详细介绍)
将图片bore.png拷贝到图形资源目录下
打开GameView.java,在其中添加一个Sprite类型变量s,并在构造函数中初始化s
//取得系统资源
Resources res = context.getResources();
//获取位图
Bitmap bmpBore = BitmapFactory.decodeResource(res, R.drawable.bore);
//创建一个Sprite对象,使用位图bmpBore,桢的宽度和高度都为16像素
s = new Sprite(bmpBore, 16, 16);
//显示在150,150位置
s.setPosition(150, 150);
//显示第0桢,第1桢和第3桢
s.setFrameSequence(new int[]{0,1,3});
在GameThread的run函数中显示这个Sprite
synchronized (surfaceHolder) {
c = surfaceHolder.lockCanvas();
//显示精灵
s.paint(c);
//显示下一桢
s.nextFrame();
Thread.sleep(200);
}
运行程序,我们可以看到一个小星星在屏幕上闪烁
第二个例子,Sprite的旋转变换。
依旧使用上一个程序,因为星星旋转起来根本没法分辨,所以这次我们使用系统为程序提供的图标文件icon.png。
首先我们定义一个Sprite数组
Sprite[] ss = new Sprite[8];
并在构造函数中初始化这个数组
Bitmap bmpIcon = BitmapFactory.decodeResource(res, R.drawable.icon);
//创建Sprite,并设置为不同的旋转方式
for(int i=0; i<8; i++){
ss[i] = new Sprite(bmpIcon);
ss[i].setTransform(i);
}
然后在run函数中显示出来
for(int i=0; i<8; i++) {
ss[i].setPosition(100, i*40);
ss[i].paint(c);
}
Thread.sleep(200);
运行一下看看效果
最后让我们来做一个碰撞检测的例子,这个例子要稍稍复杂一些。先说一下设计思路:
这是一个小游戏,在一个320x320的区域中,我方坦克在最中间。从上下左右4个方向有敌人坦克向中间进攻。我方坦克可以向四个方向发射子弹,使用方向键改变方向,使用空格键发射,但同时最多只能发射两颗子弹。敌人坦克有三种类型,其中是普通坦克,的速度是普通坦克的一倍,速度与普通坦克相同,但是需要击中两次才能被摧毁。
首先我们需要引入几个图形文件
分别为子弹、敌方坦克、爆炸效果和我方坦克。
在GameView中声明变量
// 背景色
Paint p = new Paint();
// 文字
Paint pntText = new Paint();
Sprite player; // 我方坦克
……
在GameView的构造函数中做一些初始化工作
// 初始化我方坦克
player = new Sprite(BitmapFactory.decodeResource(res,
R.drawable.player1), 16, 16);
player.setFrameSequence(new int[] { 0, 1 });
……
增加按键响应事件
public boolean onKeyDown(int keyCode, KeyEvent event)
最后就是在GameThread的run函数中完成游戏逻辑了。具体内容请看本章的代码,代码中有详细的注释。
本章示例程序 http://u.115.com/file/f1fd539783