Android开发俄罗斯方块

       俄罗斯方块是一款非常耐玩的益智小游戏,老少皆宜。(百科:http://zh.wikipedia.org/wiki/俄罗斯方块)开发俄罗斯方块,能巩固自己的数据结构知识,锻炼自己的逻辑思维能力,以及对自己的编程能力也有所提高。
       由于本次开发的俄罗斯方块属于2D游戏,所以我将使用原生API代替开发3D游戏更加优秀的Unity引擎。

       我为何要使用原生API来开发俄罗斯方块游戏?因为对于俄罗斯方块这种特殊类型的游戏,使用原生API相对简单明了。如果使用游戏引擎,需要花大把的时间去学习引擎的使用方法,很多游戏引擎缺少大量的范例和说明文档,学习起来非常吃力。不过,使用那些比较完善且简单易学的游戏引擎也是不错的选择。

 

开发日志:1.架构开发及算法设计。

                  2.游戏完善。

                  3.代码复审,修正设计模式错误,改进设计模式。

                  4.最终测试。

       下面我将详细讲述我的Android版俄罗斯方块开发过程。


一、游戏设计分析。

游戏的核心是方块,每一种方块都是由4个基本小方块构成。有如下7种形状:

Android开发俄罗斯方块_第1张图片

分别可以用字母I、J、L、O、S、T、Z表示。为了能让方块用代码表示,这里使用一个4x4的矩阵表示,即为:

⑴  I  :

[ , 0 , 0 , 0 ]

[ , 0 , 0 , 0 ]

[, 0 , 0 , 0 ]

[ 1 , 0 , 0 , 0 ]

 ⑵  J  :

[ 0 , 0 ,, 0 ]

[ 0 , 0 , , 0 ]

[ 0 , , 1 , 0 ]

[ 0 , 0 , 0 , 0 ]

 ⑶  L  :

[ 0 , , 0 , 0 ]

[ 0 , , 0 , 0 ]

[ 0 , , 1 , 0 ]

[ 0 , 0 , 0 , 0 ]

⑷  O  :

[ 0 , 0 , 0 , 0 ]

[ 0 , , 1 , 0 ]

[ 0 ,, 1 , 0 ]

[ 0 , 0 , 0 , 0 ]

⑸  S  :

[ 0 , 0 , 0 , 0 ]

[ 0 , 0 , , 1 ]

[ 0 ,1 , 0 ]

[ 0 , 0 , 0 , 0 ]

⑹  T  :

[ 0 , , 0 , 0 ]

[ 0 ,1 , 0 ]

[ 0 , , 0 , 0 ]

[ 0 , 0 , 0 , 0 ]

 ⑺  Z  :

[ 0 , 0 , 0 , 0 ]

[ 1 1 , 0 , 0 ]

[ 0 ,, 1 , 0 ]

[ 0 , 0 , 0 , 0 ]


       游戏的场地是一个类似于棋盘(Chessboard)的方块堆,为了方便图形绘制,所以这里设置一个坐标系。水平方向为X轴,竖直方向为Y轴。如下图:

Android开发俄罗斯方块_第2张图片


       棋盘坐上角位置为坐标系原点,方块在棋盘中最大横坐标为棋盘总列数(Column)减一,最大纵坐标为棋盘最大行数(Row)减一。

 

       下面将设计一个重要的算法,就是翻转方块的算法,因为实际上方块在内存中的表示方法为一个数组,翻转方块就是改变数组中一些值,这里同样设置一个坐标系来方便计算。如下图:

Android开发俄罗斯方块_第3张图片

       数组的下标是从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,或大于最大坐标。方块的绘制如图所示:

Android开发俄罗斯方块_第4张图片

       此示例方块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开发俄罗斯方块_第5张图片


四、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

        当手指从屏幕上离开时,删除Array中的按压点:

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;

       这里之所以在手指按在屏幕上时就触发“方向下”按钮的事件,而在手指离开屏幕时触发“方向左”、“方向右”、“方向上”按钮的事件,是因为“方向下”按钮触发的是持续状态事件,即按下状态时方块下落速度加快,“方向左”、“方向右”按钮触发的是一次性事件,即点一下方块往左(右)移动一格的距离,“方向上”也是如此,点一下即让方块旋转90度。


五、布局设置。

        来看看布局文件的完整代码:(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类)耦合度过高。

       错误修正方法:采用观察者模式重新设计结构,实现事件侦听器,降低耦合度。


       类图:

Android开发俄罗斯方块_第6张图片

       包结构:

       Android开发俄罗斯方块_第7张图片

       源码『游戏事件类』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 data = new Vector();

	public GameParam(Object... params) {
		for (Object o : params) {
			data.add(o);
		}
	}

	public int getIntData(int position) {
		if (data.size() > 0 && position < data.size()) {
			Object o = data.get(position);
			if (o instanceof Integer) {
				return ((Integer) o).intValue();
			}
		}
		return -1;
	}

	public String getStringData(int position) {
		if (data.size() > 0 && position < data.size()) {
			Object o = data.get(position);
			if (o instanceof String) {
				return ((String) o).toString();
			}
		}
		return null;
	}
} 
  

           源码『游戏管理类』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


你可能感兴趣的:(Android)