j2me专业手机游戏开发基础

时间:2022-09-14 09:07:10
游戏的基本结构
转自http://java.chinaitlab.com/embed/724644.html Java频道-中国IT实验室
 既然是时间驱动,游戏中就会有帧的概念。所谓帧就是某个时刻显示在屏幕上的画面。从整体上看,游戏就是一系列的帧不断播放着,像动画片一样,不过玩家可以通过交互改变播放的内容。而我们开发游戏的主要任务就是安排每一帧的内容。在每一次游戏循环中,我们需要搜集玩家的输入、运行逻辑以更新游戏的数据、根据更新后的数据安排下一帧显示的内容。 所以一个最简单的游戏结构就是:

0 初始化游戏
1 是否结束游戏(Yes:转到6)
2 搜集玩家输入信息
3 运行游戏逻辑
4 更新下一帧,显示下一帧
5 回到1
6 清理,结束游戏
 
       这是一个最基本的结构,特别对于比较简单的J2ME游戏来说,这个结构更加有代表性。下面我们将分别讲述专业手机游戏如何实现这个结构中的各个内容。
 
 游戏循环的实现

       我们需要一个进入后就一直循环下去直到游戏结束的结构。线程正好可以实现。最通常的做法是让Canvas实现Runnable接口。于是我们就可以实现run方法。下面是一个run方法简化版:
public void run(){
                        exitMidlet = false ;

                         long startTime = 0 ;

                        long timeCount = 0 ;

                        gameInit() ;

                        int curKey = 0 ;

                        while (!exitMidlet) {

                                    startTime = System.currentTimeMillis();  

                                    //acquire key

                                    acquireKey() ;

                                    //call game loop

                                    gameLoop() ;  

                                    //repaint the screen

                                    repaint();

                                    serviceRepaints();

                                    frameCount++ ;   

                                    //lock fps  

                                    timeCount = MIN_DELAY - (System.currentTimeMillis() - startTime);
                                    timeCount = (timeCount<1)?1:timeCount ;  
                                    
                                    try {
                                                Thread.sleep(timeCount);
                                                
                                    } catch (InterruptedException ex) {}

                        }
                        endMidlet() ;

}  

    看到我们的while循环了吗?除非在程序逻辑中设定exitMidlet为true-那是当玩家选择了退出游戏,我们的游戏将一直运行下去。在while循环之前,gameInit方法的作用是进行游戏初始化-比如初始化变量值,载入全局数据,生成全局对象等。在while循环中,我们先是调用了acquireKey方法,这个方法将键盘输入信息进行缓冲以便逻辑中判断按键状态,下面讲会讲到键盘缓冲。gameLoop是我们游戏的主体,每帧中的逻辑运算,图形处理都在这里面进行。然后是repaint和serviceRepaints,刷新屏幕-新的一帧呈现在屏幕上。最后当跳出while之后,我们执行endMidlet结束这个Midlet。endMidlet的内容只是调用了destroyApp和notifyDestroyed方法。好了整个游戏循环就是这样了,下面将分别讲述键盘缓冲和gameLoop如何组织。不过再这之前先让我解释下lock fps。FPS就是Frame per second。为了防止游戏在不同的机器上速度变化太大,我们设定一个最大的FPS值,或者说设置一个每帧至少要花费的时间(这里的MIN_DELAY)。比如我们设置MIN_DELAY=50,那么max FPS = 1000/50 = 20 帧/秒。锁定FPS有多种方法,这里的方法是判断如果一帧所有的时间还没达到最大时间,那么就让线程sleep一会儿。顺便再说一下FPS的计算,顾名思义用1000除以一帧所用时间即可,不过要注意的是,一般计算的FPS是平均FPS,所以FPS=累计帧数*1000/累计花费时间。


   搜集玩家输入信息是一个很重要的内容,我们知道J2ME中可以在keyPressed和keyReleased事件中处理按键内容,但这样势必将逻辑代码分散到gameLoop和keyPressed中。如果你说将所有逻辑代码放在keyPressed中,那可不行,因为keyPressed只有在按键的时候才产生,而即使没有按键游戏也有很多逻辑运算要做。所以专业游戏开发中采用键盘缓冲将按键信息存起来,然后在gameLoop中就可以判断这一帧按键的状态,利用按键缓冲,除了可以判断一个键是否按下松开,还可以判断一个键是否一直被按住了,甚至可以判断组合键。不过在这里,我只介绍一种最简单的按键缓冲。由于篇幅所限,只讲述原理并不给出具体代码。

    首先我们需要一个数据结构存储按键信息。你可以为每个键用一个bool值存储它的状态,不过专业一点的做法是用一个位表示一个键的状态,一个int有32个位,足够表示大多数手机的所有按键了。因为我们要判断键是否按下或松开,为了方便,我们再用2个整数记录这两种状态。所以现在我们一共有三个int了:  

static int key , pressedKey, releasedKey ;  

      OK! 有了存储的地方,我们还需要一些常量表示每个位,我们设定key中某个位为0表示某键没有按下,为1表示按下。如果用第1位表示0键,第2位表示1键,那么可以这样设置常量:

final static int GKEY_0=1<<0, GKEY_1=1<<1 ;  

   明白了吗?这些常量是用来指定某个位用的,比如GKEY_1其实就是第2为1其它位均为0的一个整数。如果还不明白,先看下面的。

keyPressed和keyReleased里将分别将按下的键和松开的键进行记录。

 public void keyPressed(int keyCode) {
 
                        int value = getKeyValue(keyCode) ;  

                        key |= value ;

                        pressedKey |= value ;
            }
            
            public void keyReleased(int keyCode)

            {
                        int value = getKeyValue(keyCode) ;
                        key ^= value ;
                        releasedKey |= value ;
            }

    在keyPressed里面,我们先将keyCode转换成我们的按键常量,就是上面的GKEY_0等。因为keyCode可不是像我们的常量那样可以用某个位表示的,而且不同手机的keyCode是有可能不一样的,所以我们必须用一个函数getKeyValue进行转化。得到常量后key|=value的作用是将key里面常量所代表的位,置1,现在你应该明白常量的作用了吧!pressedKey|=value同理。不过keyReleased有些不同,由于releasedKey只记录这一帧里哪些键“被松开”了,所以仍然用或运算。但key是记录整个按键的状态,所以用异或。
    
    接下来就是如何判断按键状态了:

private static void acquireKey() {

                frameKey = key ;

                framePressedKey = pressedKey ;

                frameReleasedKey = releasedKey;

                pressedKey = 0 ;

                releasedKey = 0 ;
            }             
            public static boolean keyHold(int gameKey)
            {  
                        return ((frameKey & gameKey)!=0) ;
            }
            public static boolean keyDown(int gameKey)
            {
                        return ((framePressedKey & gameKey)!=0) ;
            }
            public static boolean keyUp(int gameKey)
            {
                        return ((frameReleasedKey & gameKey)!=0) ;
            }

   还记得acquireKey吧,我们在while循环中首先要调用它,其作用就是记录下这一帧的按键状态,所有我们用了三个新int变量记录他们,分别是frameKey,framePressedKey和frameReleasedKey。acquireKey所做的就是将按键状态记录在这3个变量中,其实framePressedKey和frameReleasedKey不是必须的,只是这样看上去比较清楚。记录完后我们将pressedKey和releasedKey清空,以便下次有键按下或松开时记录新的信息。关键的三个函数登场了,keyDown判断某个键是否在这一帧里被按下,参数gameKey是我们定义的按键常量中的某个值。如果对位运算还算明白的话,很容易看懂这3个函数。唯一要说明的就是keyHold和keyDown的区别,keyHold表示某个键一直被按着,也就是至少从前一帧开始它就被按下了,而不是在这一帧里被按下的。现在你应该明白我们为什么要清空pressedKey和releasedKey了。

    说到这里也差不多了,有了这个按键缓冲我们就可以在gameLoop中调用keyDown等方法判断按键的状态了。不过我还是要说一句,这只是最简单的按键缓冲,只能缓冲一次按键,如果一个键被多次按下就不行啦。更专业的内容需要你在实际工作中探索。

状态机是编译原理的内容,看上去挺复杂的,不过说白了就是选择分支结构。但我为什么要提状态机呢?其实它是一个简化问题的好工具。再复杂的问题都可以被分解成若干小问题去解决。虽然一个游戏很复杂,但我们把它分解成若干块,分而治之,就简单多了。分类的依据就是状态。我们可以将一个游戏划分成很多状态。比如主菜单状态,控制主角状态,暂停状态等。在状态中可以再细分子状态。一直分下去,直到问题变简单。下面看看某个游戏的gameLoop片断。

public void gameLoop(){

            switch(gameState){

            case GS_Logo:

                logic_Logo() ;

                break ;

            case GS_MainMenu:

                logic_MainMenu() ;

                break ;

            case GS_PlayerControl:  

                logic_PlayerControl() ;

                break ;

            case GS_PauseMenu:

                logic_PauseMenu() ;

                break ;

            case GS_GameOver:
            
                endGame() ;

                break ;

            }
    }    

   针对不同的状态进入不同的逻辑处理函数,问题就简单多了。从某个角度来说,游戏就是状态的集合,我们要处理好状态之间的转换,写好每个状态的代码。在写状态机之前,最好先画出状态转换图。这样条理清楚,而且便于观看整个游戏的结构。举个例子,下面是一个ARPG游戏中敌人的AI状态图:(图略)
从状态转换图很容易得到AI代码,而且容易检查错误。常犯的错误是没有处理所有可能的状态。
画出状态转换图并仔细检查是个不错的方法.当然对于简单的AI就不必要麻烦了。中等复杂的AI画一个简单的小图就可以解决问题。

 渲染

    首先解释一下渲染这个词,英文是render,用在这里只是绘制的意思,这么用只是一个习惯。上面讲的run函数中并没有任何渲染的代码。这是由J2ME的结构决定的,所有的绘制代码被放在了paint里面。当然如果你使用一个Muttable Image缓冲屏幕,也可以在gameLoop里面对这个Muttable Image进行绘制,然后在paint里一次性的将这个Image贴到屏幕上。不过由于大多数手机硬件上支持双缓冲-paint的参数来自一个后台缓冲,这么做并没有太大的意义,反而白白浪费一块内存。所以J2ME游戏中的渲染往往放在paint中。

    在paint里面我们进行绘制工作也是分状态进行的,比如logo状态,我们在gameLoop里面根据时间(可以用run里面的那个frameCount代替真实时间)设置下一帧将要显示的logo图片,然后在paint里面将当前的图片画出来。其实也有一种写法是将gameLoop的代码写在paint中,因为paint是通过repaint和serviceRepaints每帧强制调用的,所以这样做完全可行。如果这样paint就是gameLoop,不需要另外一个gameLoop了。

    整个游戏的渲染可以按层次分几块进行。首先是场景的渲染,然后是场景中的所有物体,我称之为Sprite。这些Sprite需要根据位置进行排序后按顺序渲染,这样才能显示出正确的遮挡关系。场景有可能是分层的,比如分两层,第一层是地面,第二层是比较高的物体如大树。这样所有的Sprite就要放在这两层之间。当然不排除有些Sprite能飞到树上面,这需要单独处理。我这里说的Sprite并不是MIDP Game API里面的Sprite类,它只是一个概念而以。其实专业手机游戏开发中很少用到Game API,往往是自己开发一套类似于Game API但功能强很多的引擎。对于初学者,可以利用Game API进行比较方便的开发,但一定不能依赖于Game API,毕竟有能力开发出比Game API更好的引擎才是专业游戏开发者应该具有的素质。而且不依赖于某某API对于移植也很有好处。所有的渲染中,最上层的一般是游戏界面(GUI)的绘制。专业的说法是HUD(Headup Display)。这些GUI包括菜单、状态条、数据信息等等。

    渲染除了绘制的简单意思,往往还有一层图形效果的意思。在PC游戏开发中,常常使用Alpha混合、饱和运算、光影等各种效果。这些效果都是通过像素操作实现的。在手机上由于设备能力的限制,这些操作往往速度非常慢,不过随着手机硬件的发展,终究会被用上。除此之外,粒子系统是一种常常使用的效果,在手机游戏开发中也经常运用。有兴趣的读者可以搜索相关的文章学习。

场景与角色

    场景与角色是构成游戏的两大要素。从程序角度说,他们是两个重要的数据结构。本节将从数据结构、渲染方法、物理作用等方面分别讲解。

1 场景管理

    场景是什么呢?场景就是游戏角色所存在的世界。在RPG、SLG、RTS等类型的游戏中,场景的概念比较明显,从视觉上看就是游戏的地图。不过从程序的角度看,场景是一种数据结构,不但包含了地图显示的图形信息也包含了角色在场景中活动所需要的物理信息和事件信息。比如地图上有些地方是不可以通过的,有些地方主角走过去会触发一个事件等等,这些信息往往包含在场景中。广义的场景还包含地图上的NPC信息,而狭义的场景仅包含地图和物理层。这里讨论狭义的场景,即不包含NPC信息的场景。

    首先讨论怎样组织图片显示场景。最常用的方法是拼Tile,俗称贴瓷砖。整个场景用有限的图块拼接而成。Game API中的TiledLayer就是这种方式。使用Tile方式,一般用一个二维数组表示一张地图。实际使用中,往往使用一维数组代替二维数组,这里为了讲解方便使用二维数组描述。二维数组很好的对应了二维坐标。二维数组的每一项对应地图上某格使用那个瓷砖,整个二维数组就表示整张地图。为了方便的编辑地图,往往需要开发地图编辑器,所见即所得的编辑地图,将生成的数据存储为文件,在游戏中通过读取文件获取数据。如果场景有多层,比如比较高的建筑物,就要编辑多层地图了。数据组织好了,渲染的时候只要按顺序将二维数组中的每一项对应的Tile在指定位置画出来。实际操作中,地图往往远远超出屏幕大小,这就涉及到卷轴的概念。在卷轴式地图中,屏幕可以看成是一个摄像机,每次只显示地图的一部分。随着主角的移动,屏幕所显示的部分跟着变化,形成卷轴。

    卷轴式地图显示的时候,从屏幕(摄像机)所覆盖的第一个Tile开始绘制。需要注意的是屏幕所能覆盖的Tile包括部分覆盖的Tile。上图中第一个Tile就是一个部分覆盖的Tile,尽管只是部分覆盖,它也是需要绘制的第一个Tile,否则屏幕上就会出现没有图像的部分了。造成部分覆盖的原因是屏幕的卷动速度并不是Tile宽度的整数倍。在实际开发中,卷轴速度往往可能是变化的,这样的卷轴显得比较自然。按照一定的顺序将屏幕所能覆盖到的所有Tile都绘制一遍,场景的绘制工作就完成了。这些Tile绘制的时候要采用相对于屏幕的坐标,而不是相对于地图的坐标。在2D游戏中经常用到屏幕坐标和地图坐标的转换。我的原则是所有的逻辑计算都统一在地图坐标上,只在最后渲染时才将逻辑坐标转换成屏幕坐标,对于场景和角色都一样。转换的方法很简单。设一个Tile(或角色)在地图上的坐标为(x,y),屏幕相对于地图的坐标为(sx,sy),那么Tile在屏幕上的坐标就是(x-sx,y-sy)。注意单位要统一,往往Tile的单位是格,所以要转换成像素单位,即格子坐标乘上Tile宽度或高度。讲到这儿你会发现,其实我们已经实现了TiledLayer的主要功能,再加上管理Tile图片的部分一个简单的Tile引擎就成形了。不过你可千万不要满足,真正的游戏开发中场景引擎比这复杂的多,除了这种2D引擎还有2.5D的斜视角引擎,在渲染速度方面更是做了多方面的优化。

    最后谈谈场景中的物理作用和事件检测。在场景中有些地方是无法通过的,有些地方走上去会触发某个机关,这些信息往往也存储在场景信息中,用另一个数组表示,一般称为物理层、地形层或事件层等等。最常用的信息就是碰撞信息,它用来处理角色和地图的碰撞。碰撞检测在游戏开发中是一个永恒的话题,而2D地图碰撞是其中最简单的一种。由于使用了物理层,我们只要计算出下一帧角色所要到达的位置是哪一格,如果这一格的物理层信息是不可通过,则组织角色前进。这种碰撞检测不同于物体间的碰撞检测,不必和所有的物体进行遍历判断,缺点是不够精确。减小物理层格子的大小可以提高精确度,但这会使数据变多。所以物理层的碰撞检测一般只用在地图上,如果有比较特殊的物体,就让它作为一个角色存在,利用角色间的碰撞检测。

2 角色管理

    角色就是存在于场景中的一切东西,它可以是活动的如RPG中的NPC,也可以是静止的,如一个箱子。角色的数据结构视作用而不同,但基本的都有坐标、速度、使用到的图片等数据。具体到不同的游戏会增加各种数据,比如RPG中,会增加很多角色属性。这里我们只谈最根本的一些东西,主要讲角色的绘制和相互作用。

    绘制角色简单的只是绘制一个帧序列的动画。不同于动画片,角色的动画根据AI变化。不同的AI对应不同的动作和动画。复杂些的角色是由很多部分组合而来,可称之为组合精灵技术,或者称为纸娃娃系统(Avatar)。这种技术可以实现换装效果。组合精灵的复杂之处就在于要组织好每个小部分之间的关系,这需要组合精灵编辑器完成。在专业游戏开发中,会大量运用到各种工具,编写工具也是游戏程序员的工作之一。无论什么样的绘制系统,在最终绘制到屏幕时都要注意两个基本点 - 角色的坐标和层次。角色的坐标是AI运算的结果,绘制时要将地图坐标转换成屏幕坐标。角色的层次需要排序得到,最后根据从远到近的顺序绘制各个角色,这样才能显示正确的遮挡关系。

    角色的物理作用分为和场景之间的物理作用和角色间的物理作用。前者已经在场景管理中讲过。角色间的碰撞检测的时机是某个角色运动时。对于RPG这种类型的游戏,一个矩形碰撞框就可以表示角色的轮廓。对于格斗等类型的游戏,需要用多个碰撞框表示角色。碰撞框的检测非常简单,只要进行矩形相交测试。也有用圆形表示碰撞框的,利用圆心距离和半径的关系就可以判断。在2D飞机类游戏中,三角型的碰撞框也常常使用到。总之用一些几何形状粗略的描述角色的轮廓是2D游戏碰撞检测的通用方法。

    场景中的所有角色往往用一个数组存储起来,这样方便遍历操作。主角做为一个例外,是需要玩家操纵的角色,而其他角色都是用AI控制的。AI虽然是人工智能的缩写,但这里借用来表示操纵角色的代码。


     场景和角色是游戏中两个重要的基础组成部分,而开发一个游戏的工作主要部分就是设计各种场景和角色,按照设计编写角色的AI。