写个简单的飞机游戏玩玩java
侯亮程序员
前些天看了《Android游戏编程之从零开始》一书中一个简单飞机游戏的实现代码,一时手痒,也写了一个练练手。虽然个人本职工做并非写游戏,不过程序员或多或少都有编写游戏的情结,那就写吧,Just for fun!游戏的代码部分我基本上所有重写了,至于游戏的图片资源嘛,我老实不客气地全拿来复用了一下,呵呵,但愿李华明先生不要见怪啊。编程
在Android平台上,SurfaceView就足以应付全部简单游戏了。固然我说的是简单游戏,若是要写复杂游戏,恐怕还得使用各类游戏引擎,不过游戏引擎不是本文关心的重点,对于我写的简单游戏来讲,用SurfaceView就能够了。canvas
飞机游戏的一个小特色是,画面老是在变更的,这固然是句废话,不过却能引出一个关键的设计核心,那就是“帧流”。帧流的最典型例子大概就是电影啦,咱们知道,只要胶片按每秒钟24帧(或者更高)的速率播放,人眼就会误觉得看到了连续的运动画面。飞机游戏中的运动画面大致也是这样呈现的,所以游戏设计者必须设计出一条平滑的帧流,而且帧率要足够快。数组
从技术上说,咱们能够在一个线程中,构造一个不断绘制“帧”的while循环,并在每次画好帧后,调用Thread.sleep()睡眠合适的时间,这样就能够实现一个相对平滑的帧流了。ide
另外一方面,游戏的逻辑也是能够融入到帧流里的,也就是说,每次画好帧后,咱们能够调用一个相似execLogic()的函数来执行游戏逻辑,从而(间接)产生新的帧。而游戏逻辑又能够划分红多个子逻辑,好比关卡背景逻辑、敌人行为逻辑、玩家飞机逻辑、子弹行为逻辑、碰撞逻辑等等,这个咱们后文再细说。函数
大概提及来就是这么多了,如今咱们逐个来看游戏设计中的细节。动画
咱们先写个全屏显示的Activity:this
public class HLPlaneGameActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); requestWindowFeature(Window.FEATURE_NO_TITLE); setContentView(new PlaneGameView(this)); } }
这个Activity的主视图是PlaneGameView类,它继承于SurfaceView。spa
public class PlaneGameView extends SurfaceView implements Callback, Runnable
一旦surface建立成功,咱们就启动一个线程,这个线程负责运做帧流。
@Override public void surfaceCreated(SurfaceHolder holder) { GlobalInfo.screenW = getWidth(); GlobalInfo.screenH = getHeight(); mSurfaceWorking = true; mGameManager = new GameManager(getContext()); mGameThread = new Thread(this); mGameThread.start(); }
mGameThread线程的核心run()函数的代码以下:
@Override public void run() { while (mSurfaceWorking) { long start = System.currentTimeMillis(); drawFrame(); // 画帧! execLogic(); // 执行全部游戏逻辑! long end = System.currentTimeMillis(); try { if (end - start < 50) { Thread.sleep(50 - (end - start)); // 睡眠合适的时间! } } catch (InterruptedException e) { e.printStackTrace(); } } }
画帧、游戏逻辑、合适的sleep,一鼓作气。为了便于计算,此处我采用了每秒20帧的帧率,因此每帧平均50毫秒,并且由于画帧和执行游戏逻辑都是须要消耗时间的,因此合适的sleep()动做应该写成:Thread.sleep(50 - (end - start))。
为了便于管理,我设计了一个GameManager管理类。这个类究竟是干什么的呢?简单地说,它整合了游戏中的全部元素,目前有:
固然,之后还能够再扩展一些东西,它们的机理是接近的。
GameManager的代码截选以下:
public class GameManager { private Context mContext = null; private GameStage mCurStage = null; private Player mPlayer = null; private EnemyManager mEnemyMgr = null; private BulletsManager mPlayerBulletsMgr = new BulletsManager(); private BulletsManager mEnemyBulletsMgr = new BulletsManager(); private ExplodeManager mExplodeMgr = null; private GameInfoPanel mGameInfoPanel = null;
GameManager的总模块关系示意图以下:
既然在“帧流”线程里最重要的动做是drawFrame()和execLogic(),那么GameManager类也必须提供这两个成员函数,这样帧流线程只需直接调用GameManager的同名函数便可。
帧流线程的drawFrame()函数,其代码以下:
public void drawFrame() { Canvas canvas = null; try { canvas = mSfcHolder.lockCanvas(); if (canvas == null) { return; } mGameManager.drawFrame(canvas); } catch (Exception e) { // TODO: handle exception } finally { if (canvas != null) { mSfcHolder.unlockCanvasAndPost(canvas); } } }
其中GameManager的drawFrame()函数以下:
public void drawFrame(Canvas canvas) { mCurStage.drawFrame(canvas); mEnemyMgr.drawFrame(canvas); mExplodeMgr.drawFrame(canvas); mPlayerBulletsMgr.drawFrame(canvas); mEnemyBulletsMgr.drawFrame(canvas); mPlayer.drawFrame(canvas); mGameInfoPanel.drawFrame(canvas); }
无非是调用全部游戏角色的drawFrame()而已。
每一个游戏角色有本身的存活期,在其存活期中,能够经过drawFrame()向canvas中的合适位置绘制相应的图片。示意图以下:
在上面的示意图中,两个enemy的生存期都只有5帧,当帧流绘制到上图的紫色帧时,会先绘制enemy_1的第1帧,然后绘制enemy_2的第5帧,最后绘制player的当前帧。(固然,这里咱们只是简单阐述原理,你们若有兴趣,能够再在这张图上添加其余的游戏元素。)绘制完毕后的最终效果,就是屏幕展现给用户的最终画面。
每一个游戏角色都很是清楚本身当前应该如何绘制,并且它经过执行本身的子逻辑,决定出下一帧该如何绘制,这就是游戏中最重要的画帧流程。
其实,游戏的总体运做是由两个方面带动的,一个是“软件内部控制”,主要控制全部“非player角色”的移动和动做,好比每一个enemy下一步移动到哪里,如何发射子弹等等;另外一个是“用户操做”,主要控制“player角色”的移动和动做(这部分咱们放在后文再说)。在前文所说的帧流线程里,是经过调用GameManager的execLogic()来完成全部“软件内部控制”的,其代码以下:
public void execLogic() { mCurStage.execLogic(); mEnemyMgr.execLogic(); mPlayer.execLogic(); mPlayerBulletsMgr.execLogic(); mEnemyBulletsMgr.execLogic(); mExplodeMgr.execLogic(); mGameInfoPanel.execLogic(); execCollsionLogic(); // 碰撞逻辑 }
从上面代码就能够看出,GameManager所管理的子逻辑大概有如下几个:
咱们先看前面execLogic()函数里的第一句:mCurState.execLogic(),这个mCurState是GameStage类型的,这个类主要维护当前关卡的相关数据。目前这个类很是简单,只维护了关卡背景图以及本关enemy的出现顺序表。
通常来讲,飞机游戏的背景是不断滚动的。为了实现滚动效果,咱们能够绘制一张比屏幕长度更长的图片,并首尾相接地循环绘制它。
在StageBg里,mBackGroundBmp1和mBackGroundBmp2这两个域其实指向的是同一个位图对象,之因此写成两个域,是为了代码更易于阅读。另外,mBgScrollSpeed用于表示背景滚动的速度,咱们能够经过修改它,来体现飞行的速度。
GameStage的另外一个重要职责是向游戏的主控制器(GameManager)提供一张表示敌人出场顺序的表,为此它提供了getEnemyMap()函数:
public int[][] getEnemyMap() { // ENEMY_TYPE_NONE = 0; // ENEMY_TYPE_DUCK = 1; // ENEMY_TYPE_FLY = 2; // ENEMY_TYPE_PIG = 3; int[][] map = new int[][] { {0, 0, 0, 0, 1, 0, 0, 0, 0}, {0, 0, 0, 1, 1, 1, 0, 0, 0}, {0, 0, 0, 1, 0, 1, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 2, 1, 0, 0, 0, 1, 2, 0}, {0, 2, 2, 1, 0, 1, 2, 2, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 1, 1}, {0, 0, 0, 0, 0, 0, 1, 1, 1}, {0, 2, 2, 0, 0, 0, 2, 2, 0}, {0, 2, 2, 0, 0, 0, 2, 2, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 3, 0, 0, 0, 0}, }; return map; }
该函数返回的二维数组,表达的就是敌人的出场顺序和出场位置。咱们目前是这样安排的,将屏幕均分为9列,每一列的特定位置对应二维数组中的一个整数,当数值为0时,表示此处没有敌人;当数值为1到3之间的整数时,分别表明此处将出现哪一种敌人。如今咱们只有3种敌人:DUCK,FLY,PIG。
这一关卡只有一个BOSS,其类型为3型,对应上面的PIG。咱们能够看到,它只会在上面出场表的最后一行出现一次。
关卡里的全部敌人最好能统一管理,因此我编写了EnemyManager类。EnemyManager的定义截选以下:
public class EnemyManager implements IGameElement { private ArrayList<Enemy> mEnemyList = new ArrayList<Enemy>(); private int[][] mEnemyMap = null; private int mCurLine = 0; private int mEnemyCounter = 0; private Context mContext = null; private EnemyFactory mEnemyFactory = null; private BulletsManager mBulletsMgr = null; private ExplodeManager mExplodeMgr = null; private Player mPlayer = null;其中mEnemyList列表中会记录关卡里产生的全部敌人,当敌人被击毙以后,程序会把相应的Enemy对象从这张表中删除。mEnemyMap记录的其实就是前文所说的敌人的出场顺序表。另外,为了便于建立Enemy对象,咱们能够先建立一个EnemyFactory对象,并记入mEnemyFactory域。
另外,咱们还须要管理全部Enemy发出的子弹,咱们为EnemyManager添加了mBulletsMgr域,意思很简单,往后每一个Enemy发射子弹时,其实都是向这个BulletsManager添加子弹对象。与此同理,咱们还须要一个记录爆炸效果的爆炸管理器,那就是mExplodeMgr域。每当一个Enemy被击毙时,它会向爆炸管理器中添加一个爆炸效果对象。
EnemyManager的绘制动做很简单,只需遍历一下所记录的Enemy列表,调用每一个Enemy对象的drawFrame()函数便可。
@Override public void drawFrame(Canvas canvas) { Iterator<Enemy> itor = mEnemyList.iterator(); while (itor.hasNext()) { Enemy b = itor.next(); b.drawFrame(canvas); } }<span style="font-family: Arial, Helvetica, sans-serif; background-color: rgb(255, 255, 255);"> </span>
执行逻辑的动做也差很少,都须要遍历Enemy列表:
@Override public void execLogic() { execAddEnemyLogic(); // 添加enemy的地方! Iterator<Enemy> itor = mEnemyList.iterator(); while (itor.hasNext()) { Enemy b = itor.next(); b.execLogic(); // 执行每一个enemy的execLogic。 } // EnemyManager还须要负责清理“已死亡”的enemy itor = mEnemyList.iterator(); while (itor.hasNext()) { Enemy b = itor.next(); if (b.isDead()) { itor.remove(); } } }
private void execAddEnemyLogic() { mEnemyCounter++; if (mEnemyCounter % 24 == 0) { if (mCurLine < mEnemyMap.length) { for (int i = 0; i < mEnemyMap[mCurLine].length; i++) { addEnemy(mEnemyMap[mCurLine][i], i, mEnemyMap[mCurLine].length); } } mCurLine++; } }
addEnemy的代码以下:
private void addEnemy(int enemyType, int colIdx, int colCount) { Enemy enemy = null; int enemyCenterX, enemyCenterY; enemy = mEnemyFactory.createEnemy(enemyType); if (null == enemy) { return; } enemy.setBulletsManager(mBulletsMgr); enemy.setExplodeManager(mExplodeMgr); enemy.setTarget(mPlayer); mEnemyList.add(enemy); switch (enemyType) { case EnemyFactory.ENEMY_TYPE_DUCK: case EnemyFactory.ENEMY_TYPE_FLY: int colWidth = (int)((double)GlobalInfo.screenW / colCount); enemyCenterX = colWidth * colIdx + colWidth / 2; enemyCenterY = -1 * enemy.getHeight(); enemy.setInitInfo(enemyCenterX, enemyCenterY, 8); break; case EnemyFactory.ENEMY_TYPE_PIG: enemyCenterX = GlobalInfo.screenW / 2; enemyCenterY = -1 * enemy.getHeight(); enemy.setInitInfo(enemyCenterX, enemyCenterY, 8); break; default: break; } }代码很简单,先利用EnemyFactory根据不一样的enemyType,建立相应的enemy对象。而后为每一个enemy设置重要的关联对象,好比mBulletsMgr、mExplodeMgr、mPlayer。这是由于enemy老是要发子弹的嘛,那么它每发一颗子弹,都要向“子弹管理器”里添加子弹对象。同理,当enemy爆炸时,它也会向“爆炸管理器”里添加一个爆炸效果对象。又由于enemy经常须要瞄准玩家发射子弹,那么它就须要知道玩家的位置信息,所以setTarget(mPlayer)也是必要的。
接着咱们将enemy对象添加进EnemyManager的mEnemyList列表中。另外还须要为不一样enemy设置不一样的初始信息,好比初始位置、运行速度等等。
游戏中全部的子弹,无论是enemy发射的,仍是玩家发射的,都必须添加进“子弹管理器”加以维护。只不过为了便于处理,咱们把enemy和玩家发射的子弹分别放在了不一样的BulletsManager里。这就是为何在GameManager里,会有两个BulletsManager的缘由:
private BulletsManager mPlayerBulletsMgr = new BulletsManager(); private BulletsManager mEnemyBulletsMgr = new BulletsManager();
BulletsManager的代码以下:
public class BulletsManager { private ArrayList<Bullet> mBulletsList = new ArrayList<Bullet>(); public void addBullet(Bullet bullet) { mBulletsList.add(bullet); } public void drawFrame(Canvas canvas) { Iterator<Bullet> itor = mBulletsList.iterator(); while (itor.hasNext()) { Bullet b = itor.next(); b.drawFrame(canvas); } } public void execLogic() { Iterator<Bullet> itor = mBulletsList.iterator(); while (itor.hasNext()) { Bullet b = itor.next(); b.execLogic(); } itor = mBulletsList.iterator(); while (itor.hasNext()) { Bullet b = itor.next(); if (b.isDead()) { itor.remove(); } } } public ArrayList<Bullet> getBullets() { ArrayList<Bullet> bullets = (ArrayList<Bullet>)mBulletsList.clone(); return bullets; } }
从代码上看,它的drawFrame()和execLogic()和EnemyManager的同名函数很像。在execLogic()中,每当发现一颗子弹已经报废了,就会把它从mBulletsList列表里删除。嗯,用isDead()来表达子弹是否报废了好像不太贴切,不过你们应该都可以理解吧,呵呵。
BulletsManager还得向外提供一个getBullets()函数,以便外界进行碰撞判断。这个咱们在后文再细说。
爆炸效果管理器和子弹管理器的逻辑代码差很少,因此咱们就不贴它的execLogic()和drawFrame()的代码了。
每一个爆炸效果会对应一个Explode对象。由于爆炸效果通常都会表现为动画,因此Explode内部必须记录下本身当前该绘制哪一张图片了。在咱们的程序里,爆炸资源图以下:
这张爆炸图会在Explode对象构造之时传入,并且外界会告诉Explode对象,爆炸图中总共有几帧。Explode的构造函数以下:
public Explode(int explodeType, Rect rect, Bitmap explodeBmp, int totalFrame) { mType = explodeType; mCurRect = new Rect(rect); mExplodeBmp = explodeBmp; mTotalFrame = totalFrame; mFrameWidth = mExplodeBmp.getWidth() / mTotalFrame; mFrameHeight = mExplodeBmp.getHeight(); }
public void execLogic() { mCurFrameIdx++; if (mCurFrameIdx >= mTotalFrame) { mState = STATE_DEAD; } }
public void drawFrame(Canvas canvas) { Rect srcRect = new Rect(mCurFrameIdx * mFrameWidth, 0, (mCurFrameIdx + 1)*mFrameWidth, mFrameHeight); canvas.save(); canvas.clipRect(mCurRect); canvas.drawBitmap(mExplodeBmp, srcRect, mCurRect, null); canvas.restore(); }
一开始计算的srcRect,表示的就是和mCurFrameIdx对应的绘制部分。
其实,不光是爆炸效果,咱们的每一类Enemy都是具备本身的动画的。它们的绘制机理和爆炸效果一致,咱们就不赘述了。下面只贴出三类Enemy的角色动画图:
如今咱们来看玩家控制的角色——Player类。它和Enemy最大的不一样是,它是直接由玩家控制的。玩家想把它移到什么地方,他就得乖乖地移到那个地方去,为此它必须可以处理MotionEvent。
public boolean doWithTouchEvent(MotionEvent event) { int x = (int)event.getX(); int y = (int)event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mOffsetX = x - mCurRect.left; mOffsetY = y - mCurRect.top; return true; case MotionEvent.ACTION_UP: mOffsetX = mOffsetY = 0; return true; case MotionEvent.ACTION_MOVE: int curX = x - mOffsetX; int curY = y - mOffsetY; if (curX < 0) { curX = 0; } if (curY < 0) { curY = 0; } if (curX + mWidth > GlobalInfo.screenW) { curX = GlobalInfo.screenW - mWidth; } if (curY + mHeight > GlobalInfo.screenH) { curY = GlobalInfo.screenH - mHeight; } mCurRect.set(curX, curY, curX+mWidth, curY+mHeight); return true; default: break; } return false; }
注意,为了保证良好的用户体验,咱们须要在用户点击屏幕之时,先计算一下手指点击处和Player对象当前所在位置之间的偏移量,之后在处理ACTION_MOVE时,还需用x、y减去偏移量。这样,就不会出现Player对象从旧位置直接跳变到手指点击处的状况。
如今咱们来讲说碰撞处理。在飞机游戏里,一种典型的碰撞状况就是被子弹击中啦。对于Player来讲,它必须逐个判断敌人发出的子弹,看本身是否已和某个子弹亲密接触,若是是的话,那么Player就得减血,若是没血可减了,就算被击毙了。
对于简单的游戏而言,咱们只需判断子弹所占的Rect范围是否和Player所占的Rect范围有交集,若是是的话,就能够认为发生碰撞了。固然,为了增长一点儿趣味性,咱们是用一个比Player Rect更小的矩形来和子弹Rect比对的,这样能够出现一点儿子弹和Player擦身而过的惊险效果。
在GameManager的execLogic()的最后一步,会调用execCollsionLogic()函数。该函数的代码以下:
private void execCollsionLogic() { mPlayer.doWithCollision(mEnemyBulletsMgr); mEnemyMgr.doWithCollision(mPlayerBulletsMgr); }
意思很简单,Player须要和全部enemy发出的子弹进行比对,而每一个enemy须要和Player发出的子弹比对。咱们只看Player的doWithCollision()函数,代码以下:
public void doWithCollision(BulletsManager bulletsMgr) { if (mState == STATE_EXPLODE || mState == STATE_DEAD) { return; } ArrayList<Bullet> bullets = bulletsMgr.getBullets(); Iterator<Bullet> itor = bullets.iterator(); int insetWidth = (int)((mCurRect.right - mCurRect.left) * 0.2); int insetHeight = (int)((mCurRect.bottom - mCurRect.top) * 0.15); Rect effectRect = new Rect(mCurRect); effectRect.inset(insetWidth, insetHeight); while (itor.hasNext()) { Bullet b = itor.next(); Rect bulletRect = b.getRect(); if (effectRect.intersect(bulletRect)) { b.doCollide(); doCollide(b.getPower()); } } }
其中那个effectRect就是比Player所占矩形更小一点儿的矩形啦。咱们遍历BulletsManager中的每一个子弹,一旦发现哪一个子弹和effectRect有交集,就执行doCollide()。
private void doCollide(int power) { if (mState == STATE_ADJUST || mState == STATE_EXPLODE || mState == STATE_DEAD) { return; } if (power < 0) { // kill me directly mState = STATE_EXPLODE; } else if (power > 0) { mMyHP -= power; if (mMyHP <= 0) { mMyHP = 0; mState = STATE_EXPLODE; } else { mState = STATE_ADJUST; mAdjustCounter = 0; } } }
若是写得复杂一点儿的话,不一样enemy发出的子弹的威力应该是不同的。不过在本游戏中,每颗子弹的威力都定为1了。也就是说,传入doCollide()的power参数的值总为1。每次碰撞时,Player就减一滴血(mMyHP -= power),而后当即跳变到STATE_ADJUST状态或STATE_EXPLODE状态。
另外一方面,enemy和Player发出的子弹也有相似的判断,只是判断条件更加宽松一些,这样能够给玩家增长一点儿射击的爽快感,呵呵。关于这部分的代码咱们就不重复贴了。
Player须要完成的另外一个效果是被击中后,闪烁一段很短的时间,在这段时间内,它会暂时处于无敌状态,这样作能够避免玩家出现被多颗子弹同时击中而被瞬杀的状况。为此咱们设计了一个“调整状态”,就是咱们刚刚看到的STATE_ADJUST状态啦。
一旦Player被击中,只要它的mMyHP(血值)没有减到0,那么它当即跳变到STATE_ADJUST。在这种状态下,咱们再也不每次都绘制Player图片了,而是隔一帧绘制一次,这样就能够达到闪烁的效果了。固然这个状态的维持时间很短,咱们会记录一个mAdjustCounter计数变量,每次执行execLogic()会给这个计数器加1,直到加到6,咱们就从STATE_ADJUST状态,跳变回普通状态(STATE_ALIVE状态)。
public void execLogic() { if (mState == STATE_ALIVE) { doFireBulletLogic(); } else if (mState == STATE_EXPLODE) { doExplode(); } else if (mState == STATE_ADJUST) { doFireBulletLogic(); mAdjustCounter++; if (mAdjustCounter > 6) { mState = STATE_ALIVE; mAdjustCounter = 0; } } }
public void drawFrame(Canvas canvas) { boolean shouldDraw = true; if (mState == STATE_DEAD) { Log.d("Player", "mState == STATE_DEAD"); return; } else if (mState == STATE_ADJUST) { if (mAdjustCounter % 2 == 0) { shouldDraw = false; } } else if (mState == STATE_EXPLODE) { // should draw } Log.d("Player", "mState == " + mState); if (shouldDraw) { Rect src = new Rect(0, 0, mPlayerBmp.getWidth(), mPlayerBmp.getHeight()); canvas.drawBitmap(mPlayerBmp, src, mCurRect, null); } }<span style="font-family: Arial, Helvetica, sans-serif; background-color: rgb(255, 255, 255);"> </span>
飞机游戏还须要一个简单的“信息显示板”,来显示一些重要的信息。在本游戏中,我只显示了Player的剩余血量(每滴血用一个红心表示),你们有兴趣能够再添加玩家分数等信息。
咱们设计的信息显示板是GameInfoPanel,它的逻辑很是简单:
public void execLogic() { mPlayerHP = mPlayer.getHP(); }
只是简单地记录一下Player的血量而已。
绘制时,它根据所记录的血量值绘制相应的红心图片就能够了:
public void drawFrame(Canvas canvas) { Rect src = new Rect(0, 0, mHPBmp.getWidth(), mHPBmp.getHeight()); Rect dest = new Rect(); for (int i = 0; i < mPlayerHP; i++) { dest.left = mRect.left + i * mHPiconWidth; dest.top = mRect.top; dest.right = dest.left + mHPiconWidth; dest.bottom = dest.top + mHPiconHeight; canvas.drawBitmap(mHPBmp, src, dest, null); } }
至此,咱们已经把这个小游戏的主要设计方面都讲到了。固然,由于这个游戏只是我为了好玩而写的一个demo程序,因此确定有不少地方并不完备,这个我想你们也是能够理解的。那么就先说这么多吧。最后让咱们来贴两张游戏截图,乐呵一下。