我为何要使用原生API来开发俄罗斯方块游戏?因为对于俄罗斯方块这种特殊类型的游戏,使用原生API相对简单明了。如果使用游戏引擎,需要花大把的时间去学习引擎的使用方法,很多游戏引擎缺少大量的范例和说明文档,学习起来非常吃力。不过,使用那些比较完善且简单易学的游戏引擎也是不错的选择。
开发日志:1.架构开发及算法设计。
2.游戏完善。
3.代码复审,修正设计模式错误,改进设计模式。
4.最终测试。
下面我将详细讲述我的Android版俄罗斯方块开发过程。
一、游戏设计分析。
游戏的核心是方块,每一种方块都是由4个基本小方块构成。有如下7种形状:
分别可以用字母I、J、L、O、S、T、Z表示。为了能让方块用代码表示,这里使用一个4x4的矩阵表示,即为:
⑴ I :
[ 1 , 0 , 0 , 0 ]
[ 1 , 0 , 0 , 0 ]
[ 1 , 0 , 0 , 0 ]
[ 1 , 0 , 0 , 0 ]
⑵ J :
[ 0 , 0 , 1 , 0 ]
[ 0 , 0 , 1 , 0 ]
[ 0 , 1 , 1 , 0 ]
[ 0 , 0 , 0 , 0 ]
⑶ L :
[ 0 , 1 , 0 , 0 ]
[ 0 , 1 , 0 , 0 ]
[ 0 , 1 , 1 , 0 ]
[ 0 , 0 , 0 , 0 ]
⑷ O :
[ 0 , 0 , 0 , 0 ]
[ 0 , 1 , 1 , 0 ]
[ 0 , 1 , 1 , 0 ]
[ 0 , 0 , 0 , 0 ]
⑸ S :
[ 0 , 0 , 0 , 0 ]
[ 0 , 0 , 1 , 1 ]
[ 0 , 1 , 1 , 0 ]
[ 0 , 0 , 0 , 0 ]
⑹ T :
[ 0 , 1 , 0 , 0 ]
[ 0 , 1 , 1 , 0 ]
[ 0 , 1 , 0 , 0 ]
[ 0 , 0 , 0 , 0 ]
⑺ Z :
[ 0 , 0 , 0 , 0 ]
[ 1 , 1 , 0 , 0 ]
[ 0 , 1 , 1 , 0 ]
游戏的场地是一个类似于棋盘(Chessboard)的方块堆,为了方便图形绘制,所以这里设置一个坐标系。水平方向为X轴,竖直方向为Y轴。如下图:
棋盘坐上角位置为坐标系原点,方块在棋盘中最大横坐标为棋盘总列数(Column)减一,最大纵坐标为棋盘最大行数(Row)减一。
下面将设计一个重要的算法,就是翻转方块的算法,因为实际上方块在内存中的表示方法为一个数组,翻转方块就是改变数组中一些值,这里同样设置一个坐标系来方便计算。如下图:
数组的下标是从0开始的,则把数组下标当作坐标来算,当i和j都为0时表示array[ 0 ][ 0 ](array [ i ][ j ]),原点为左上角(i=0,j=0)的位置建立的坐标系设为“绝对坐标”(Absolute Coordinate),以上图中坐标的原点位置为原点的坐标系设为“相对坐标”(Relative Coordinate)。
则相对坐标的原点处于绝对坐标系中(1.5,1.5)的位置。因此,可以得出相对坐标和绝对坐标的相互转换公式如下:
相对坐标 = 绝对坐标 – 1.5f ;
绝对坐标 = 现对坐标 + 1.5f ;
得出换算公式之后,就可以计算翻转了。这里的翻转正好是以相对坐标原点为旋转中心点进行旋转。每次翻转方块,旋转角度都是90度,因此可以用齐次坐标变换公式得出旋转前后坐标关系的公式如下:
x’ = -y;
y’ = x;
总而言之,每次旋转的完整步骤就是,先由绝对坐标(数组下标值)转换为相对坐标,再套用旋转公式得到旋转后的相对坐标,最后再转换为绝对坐标。
二、算法设计与分析。
『越界检测』 算法。判断方块是否越过边界,边界内即棋盘范围内。越界即方块在棋盘坐标系中的坐标小于0,或大于最大坐标。方块的绘制如图所示:
此示例方块I的第一行第一列的小方格的绝对坐标(在棋盘坐标系中的坐标)决定了整个方块的绝对坐标,而这个方块中的任意一个小方格的相对坐标(在方块坐标系中的坐标)等价于小方格在数组中的下标(i,j)。计算越界时,需要逐行逐列扫描每一个小方格是否越界,因此必须要计算方块中(i,j)代表的小方格的绝对坐标。
i代表行号,j代表列号。左越界应该从右边第一列开始扫描,计算方块所占用的最左边一列的列号:
hold = 3;
for (i = 0; i <= 3; i++) {
for (j = 3; j >= 0; j--) {
if (mblocks[i][j] == 1) {
hold = (j < hold) ? j : hold;
}
}
}
算出占用列号过后就能知道是否发生左越界了:
public boolean overBorderLeft() {
if (holdLeft + x > 0) {
return false;
}
return true;
}
其他方向越界同理。右越界(j = 0 -> 3 ; i = 0 -> 3;)、上越界(i = 3 -> 0 ; j = 0 -> 3;)、下越界(i = 0 -> 3 ; j = 0 -> 3 ;)。
『碰撞检测』算法。因为棋盘中存在已经下落的方块,正在下落的方块能否移动到某些位置,需要判断那些位置是否已经被小方格占用,碰撞检测算法则是用来做这个事的。相比越界检测,碰撞检测只需要检测三个方向,即是否能左移、右移、下落。
碰撞检测与越界检测是同理的。以是否能左移来说:目的是扫描每一个方格,如果此处有方格,且它左边相邻格处于被占用状态,则不能左移。算法表示为:
public boolean canMoveLeft(int[][] fallenBlocks, int xMax) {
boolean canMove = true;
for (j = 0; j <= 3; j++) {
for (i = 0; i <= 3; i++) {
if (childX(j) <= xMax && childX(j) - 1 >= 0) {
if (blocks[i][j] == 1 && fallenBlocks[childY(i)][childX(j) - 1] == 1) {
canMove = false;
}
}
}
}
return canMove;
}
是否能右移:
public boolean canMoveRight(int[][] fallenBlocks, int xMax) {
boolean canMove = true;
for (j = 3; j >= 0; j--) {
for (i = 0; i <= 3; i++) {
if (childX(j) + 1 <= xMax && childX(j) >= 0) {
if (blocks[i][j] == 1 && fallenBlocks[childBlockY(i)][childBlockX(j) + 1] == 1) {
canMove = false;
}
}
}
}
return canMove;
}
是否能下落:
public boolean canFallDown(int[][] fallenBlocks, int yMax) {
boolean canFall = true;
for (i = 3; i >= 0; i--) {
for (j = 0; j <= 3; j++) {
if (childY(i) + 1 <= yMax && childY(i) >= 0) {
if (blocks[i][j] == 1 && fallenBlocks[childY(i) + 1][childX(j)] == 1) {
canFall = false;
}
}
}
}
return canFall;
}
三、游戏框架设计。
代码结构:
包名 |
类名 |
说明 |
com.EM.MyTetris.activity |
MainActivity |
主要活动类 |
com.EM.MyTetris.game |
Game |
游戏类,负责游戏状态控制 |
GameUI |
游戏UI类,基本游戏界面数据存储 |
|
Menu |
菜单类,存储菜单名称 |
|
Tetris |
方块类,存储方块状态等数据 |
|
com.EM.MyTetris.view |
GameView |
View子类,用于绘制游戏内容 |
四、Android API实现基础。
SurfaceView:
SurfaceView是Android游戏开发实现的基础。它继承自View,但与View不同的是,View是在UI的主线程中更新画面,如果用于游戏画面绘制,频繁的刷新画面,会导致主线程卡死。而SurfaceView嵌套了一个专门用于绘制的Surface,相当于使用了一个独立线程来绘制画面,这样就不会导致主UI线程堵塞,所以更适用于游戏画面绘制。
本游戏的画面显示等工作就交给SurfaceView了,所以本游戏的核心是我们自定义的继承自它的GameView类。(SurfaceView参考文档:http://developer.android.com/reference/android/view/SurfaceView.html)
GameView框架搭建方法:
public class GameView extends SurfaceView implements Callback, Runnable{
…
}
这里添加了接口android.view.SurfaceHolder.Callback,用于监控surface的状态,以便做出相应处理。添加Callback接口将重写如下函数:public void surfaceCreated(SurfaceHolder holder){
}
public void surfaceChanged(SurfaceHolder holder,int format,int width,int height){
}
public void surfaceDestroyed(SurfaceHolder holder){
}
我们需要在surfaceCreated()这个函数中进行基本游戏界面的绘制,并使游戏运行状态设置为允许开始游戏(gameViewReady=true),也就是GameView准备完毕,可以启动。如果在surface还没有创建的情况下尝试绘图将会出错。
游戏进行中的绘图是在一个独立线程中执行的,需要在surfaceDestroyed()这个函数中执行停止这个独立线程的操作,如果surface被销毁了,继续尝试绘图,同样会出现错误。
我们将为GameView设置一个公共函数:publicvoid startGame(){…},以供游戏控制类Game类启动游戏进程。
添加接口Runnable将重写如下函数:
public void run(){ ... }
这个Runnable将执行的是游戏进行状态中的绘图等操作,循环执行直到游戏运行状态被置为停止。代码大致如下:
while(gameRun){
…
try{
Thread.sleep(time);
…
}
catch(Exception e){
…
}
}
onTouchEvent(MotiveEventevent):
游戏的控制方法是使用虚拟按键(GameView绘制)来进行控制,为了响应用户操作,这里将使用多点触控的监控方法来实现。其实现的方法是基于View基类的onTouchEvent方法。原文请参考(http://www.vogella.com/tutorials/AndroidTouch/article.html)
重写基类的onTouchEvent函数,在里面写入如下函数:int pointerIndex = event.getActionIndex();
int pointerId = event.getPointerId(pointerIndex);
PointF p = new PointF();
通过获取每一个触控点所产生的事件顺序,来获取对应的触控点ID,这是为了之后在响应事件的情况下得到触控点的坐标。
switch(event.getActionMasked()){…}
然后针对特定的事件做相应的处理:
case MotiveEvent.ACTION_DOWN:
case MotiveEvent.ACTION_POINTER_DOWN:{
p.set(event.getX(),event.getY());
mActivePointers.put(pointerId,p);//当按下时,把坐标存储到Array中
if(gameUI.isBtnClicked(“Bottom”,p)){
//当虚拟键“下”被按下时执行
}
}
break;
case MotiveEvent.ACTION_MOVE:
for(int size=event.getPointerCount(),i=0;i
case MotiveEvent.ACTION_UP:
case MotiveEvent.ACTION_POINTER_UP:
case MotiveEvent.ACTION_CANCEL:
p=mActivePointers.get(pointerId);
//判断坐标位置,是否识别为点击虚拟按键
if(gameUI.isBtnClicked(“left”,p)){}
if(gameUI.isBtnClicked(“right”,p)){}
if(gameUI.isBtnClicked(“top”,p)){}
mActivePointers.remove(pointerId);
break;
五、布局设置。
来看看布局文件的完整代码:(activity_main.xml)
这里使用了FrameLayout,这种布局使得FrameLayout的子对象像一叠卡片一样,每一个子对象就好比一张卡片,从下至上叠起来,排在前面的子对象处于下层,排在后面的子对象处于上层,而我们是从上往下看的,如果“卡片”不是透明的,那只能看到上面第一张卡片的内容。
这里设置了两个子对象,一个是GameView,另一个是一个线性布局(LinearLayout)。GameView处于下层,用于显示游戏内容;线性布局用于显示菜单选项等内容,它将被设置为半透明。所以这里的界面布局设计思路就是,菜单选项界面半透明状态,能够看到后面被遮盖的游戏画面。当点击菜单选项“开始游戏”之后,上层菜单布局将被隐藏。
六、游戏逻辑循环结构。
游戏的核心代码是run()函数中的循环结构:
@Override
public void run() {
while (gameRun) {//注释1
try {
Thread.sleep(100);
if (!gameOver) {
drawUpdateFrame();//注释2
drawPrevision();
drawLevelAndScore();
deleteRow();
if (!gamePause) {//注释3
if (!currentTetris.overBorderBelow(fallenBlocks.length - 1)
&& currentTetris.canFallDown(fallenBlocks,fallenBlocks.length - 1)) {
currentTetris.fallDown(fallSpeed);
} else {
if (!isGameOver()) {//注释4
updateFallenBlocks(currentTetris);
currentTetris = nextTetris;
createNextTetris();
} else {
this.gameOver = true;
}
}
}
}
} catch (Exception e) {
System.err.println("Exception Catched : " + e.getMessage());
}
}
}
这里一共有几个比较重要的地方:
1.这里的布尔值gameRun用于强制停止绘图线程,退出Activity时必须执行这个操作,否则会因为找不到绘图的容器(Canvas)而出错。
2.这里的几个名称以draw开头的几个函数都是用来绘制图形的,因为游戏的数据每帧都在发生变化,所以需要每帧都执行一次绘图函数。
3.这里的布尔值gamePause用于游戏暂停功能的实现,这个if语句所包含的内容是游戏数据的更新,比如当前的方块的坐标值、当前已经停止下落的方块的数据等。
4.用于判断是否因为当前方块无法下落而导致游戏结束,如果判断为游戏结束,则应提示游戏控制器(Game类的实例)跳转至游戏结束的得分画面。
七、开发心得。
从这次俄罗斯方块游戏的开发中,我明白了:游戏设计阶段所考虑的逻辑算法,只能算是纸上谈兵,如果拿到实际运行当中,便会出现很多问题。但是正是这样一步一步的发现问题,然后解决问题,才能学到东西。
初步开发测试版:
源码下载地址:http://download.csdn.net/detail/peceoqicka/7114689
APK下载地址:http://dl.vmall.com/c0ecwvwuye
八、代码复审及错误修正。
经过代码复审,发现以下问题:
游戏管理类(Game类)和游戏绘图类(GameView类)以及主要活动类(MainActivity类)耦合度过高。
错误修正方法:采用观察者模式重新设计结构,实现事件侦听器,降低耦合度。
类图:
包结构:
源码『游戏事件类』GameEvent.java:
package com.em.amazingtetris.game.control;
import java.util.Observable;
public class GameEvent extends Observable {
private GameParam param;
private GameEventType type;
public GameEvent(GameEventType type) {
this.type = type;
notifyEventListener();
}
public GameEvent(GameEventType type, GameParam param) {
this.type = type;
notifyEventListener(param);
}
public GameEventType getEventType() {
return this.type;
}
public GameParam getGameParam() {
return param;
}
protected void notifyEventListener() {
super.addObserver(GameManager.getInstance());
super.setChanged();
super.notifyObservers();
}
protected void notifyEventListener(GameParam param) {
super.addObserver(GameManager.getInstance());
super.setChanged();
super.notifyObservers(param);
}
}
源码『游戏事件类型枚举类』GameEventType.java:
package com.em.amazingtetris.game.control;
public enum GameEventType {
GAME_START(0), GAME_PAUSE(1), GAME_RESUME(2), GAME_READY(3), GAME_OVER(4);
private int value;
private GameEventType(int _value) {
this.value = _value;
}
public int getValue() {
return this.value;
}
}
源码『游戏参数类』GameParams.java:
package com.em.amazingtetris.game.control;
import java.util.Vector;
public class GameParam {
private Vector
源码『游戏管理类』GameManager.java:
package com.em.amazingtetris.game.control;
import java.util.Observable;
import java.util.Observer;
import com.em.amazingtetris.view.GameView;
public class GameManager implements Observer {
public static final int GAME_READY = 0;
public static final int GAME_PLAYING = 1;
public static final int GAME_PAUSE = 2;
public static final int GAME_STOP = 3;
private int gameState;
private GameView gameView;
private static GameManager instance;
private IGameEventListener listener = null;
private boolean enableGameRun = false;
private GameManager() {
}
public static GameManager getInstance() {
if (instance == null) {
synchronized (GameManager.class) {
if (instance == null) {
instance = new GameManager();
}
}
}
return instance;
}
public int getGameState() {
return gameState;
}
public void setGameView(GameView gameView) {
this.gameView = gameView;
}
public boolean gameStart() {
if (gameView != null) {
if (enableGameRun) {
gameView.gameStart();
return true;
} else {
System.out
.println("ERROR!!!! GameView is not created yet");
}
} else {
System.out
.println("ERROR!!!! GameView is not correctly initiallized");
}
return false;
}
public void gamePause() {
if (gameView != null) {
gameView.gamePause();
}
}
public void gameResume() {
if (gameView != null) {
gameView.gameResume();
}
}
public void gameStop() {
if (gameView != null) {
gameView.gameStop();
gameState = GAME_STOP;
}
}
@Override
public void update(Observable observable, Object data) {
System.out.println("update()");
GameEvent event = (GameEvent) observable;
GameParam param = (GameParam) data;
if (listener != null) {
switch (event.getEventType()) {
case GAME_START:
gameState = GAME_PLAYING;
listener.onGameStart();
break;
case GAME_PAUSE:
gameState = GAME_PAUSE;
listener.onGamePause();
break;
case GAME_RESUME:
gameState = GAME_PLAYING;
listener.onGameResume();
break;
case GAME_READY:
enableGameRun = true;
break;
case GAME_OVER:
System.out.println("GAME_OVER");
gameState = GAME_READY;
listener.onGameOver(param.getIntData(0));
break;
default:
break;
}
}
}
public void setGameEventListener(IGameEventListener listener) {
this.listener = listener;
}
public interface IGameEventListener {
public void onGameStart();
public void onGamePause();
public void onGameResume();
// public void onGameStop();
public void onGameOver(int param);
}
}
重新设计过后,GameView中发生任何事(包括游戏开始,游戏结束等事件),都将生成一个新的GameEvent对象,而监听者(游戏管理类GameManager)检测到GameEvent的类型和传递的事件参数,将通过接口IGameEventListener通知活动类(MainActivity类)执行相应的界面处理操作。
package com.em.amazingtetris.activity;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.em.amazingtetris.R;
import com.em.amazingtetris.game.Menu;
import com.em.amazingtetris.game.control.GameManager;
import com.em.amazingtetris.game.control.GameManager.IGameEventListener;
import com.em.amazingtetris.view.GameView;
public class MainActivity extends Activity {
private GameView gameView;
private GameManager gameManager;
private TextView tvScore;
private LinearLayout layoutMenu;
private Button btnMenuA, btnMenuB;
private OnMenuBtnClickListener onMenuBtnClickListener;
private IGameEventListener gameListener;
...
private class GameListener implements IGameEventListener {
@Override
public void onGameStart() {
System.out.println("onGameStart()");
layoutMenu.setVisibility(View.GONE);
}
@Override
public void onGamePause() {
System.out.println("onGamePause()");
layoutMenu.setVisibility(View.VISIBLE);
setMenuBtnText(Menu.MENU_GAME_PAUSE);
}
@Override
public void onGameResume() {
System.out.println("onGameResume()");
layoutMenu.setVisibility(View.GONE);
}
@Override
public void onGameOver(int param) {
System.out.println("onGameOver()");
layoutMenu.setVisibility(View.VISIBLE);
System.out.println("layoutMenu can be see");
tvScore.setVisibility(View.VISIBLE);
tvScore.setText("您的得分:\t" + param);
setMenuBtnText(Menu.MENU_GAME_STOP);
}
}
}
以上所使用的IGameEventListener的设计模式请参看我的另一篇博文:
http://blog.csdn.net/peceoqicka/article/details/25071755
经过重新设计结构,最终测试可以正常运行。
九、未解决问题。
在游戏结束时,无法让结束游戏画面(菜单)显示,游戏暂停(菜单)都能显示,暂时未找出BUG原因。
游戏结束事件可触发,可在触发后(Activity中)获得游戏得分数据,但不显示游戏画面。
欢迎各位批评指正,纠错指错。
代码复审修正版源码:
http://download.csdn.net/detail/peceoqicka/7299277