安卓游戏入门指南(二)
五、Android 游戏开发框架
你可能已经注意到了,我们已经读了四章,却没有写一行游戏代码。我们让你经历所有这些无聊的理论并让你实现测试程序的原因很简单:如果你想写游戏,你必须知道到底发生了什么。你不能只是从整个网络上复制和粘贴代码,并希望它将形成下一个第一人称射击游戏。到目前为止,您应该已经牢牢掌握了如何从头开始设计一个简单的游戏,如何为 2D 游戏开发构建一个好的 API,以及哪些 Android APIs 将提供实现您的想法所需的功能。
为了让 Nom 先生成为现实,我们必须做两件事:实现我们在第三章设计的游戏框架接口和类,并在此基础上,编写 Nom 先生的游戏机制。让我们从游戏框架开始,把我们在第三章中设计的和我们在第四章中讨论的结合起来。90%的代码你应该已经很熟悉了,因为我们在前一章的测试程序中已经介绍了大部分。
行动(或活动、袭击)计划
在第三章第一节中,我们为游戏框架设计了一个最小的设计,它抽象出了所有的平台细节,这样我们就可以专注于我们的目标:游戏开发。现在,我们将以自下而上的方式实现所有这些接口和抽象类,从最容易到最难。第三章的接口位于 com . badlogic . Android games . framework 包中,我们将这一章的实现放在 com . badlogic . Android games . framework . impl 包中,并指出它保存了 Android 框架的实际实现。我们将用 Android 作为所有接口实现的前缀,这样我们就可以将它们与接口区分开来。让我们从最简单的部分开始,文件 I/o。
本章和下一章的代码将被合并到一个 Eclipse 项目中。现在,你可以按照第四章中的步骤在 Eclipse 中创建一个新的 Android 项目。此时,您将默认活动命名为什么并不重要。
AndroidFileIO 类
最初的 FileIO 接口是精简的,也是低劣的。它包含四个方法:一个获取素材的输入流,另一个获取外部存储中文件的输入流,第三个返回外部存储设备上文件的输出流,最后一个获取游戏的共享首选项。在第四章中,您学习了如何使用 Android APIs 打开外部存储上的素材和文件。清单 5-1 基于来自第四章的知识,展示了 FileIO 接口的实现。
清单 5-1 。**【AndroidFileIO.java】;实现 FileIO 接口
package com.badlogic.androidgames.framework.impl;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.AssetManager;
import android.os.Environment;
import android.preference.PreferenceManager;
import com.badlogic.androidgames.framework.FileIO;
public class AndroidFileIO implements FileIO {
Context context;
AssetManager assets;
String externalStoragePath;
public AndroidFileIO(Context context) {
this.context = context;
this.assets = context.getAssets();
this.externalStoragePath = Environment.getExternalStorageDirectory()
.getAbsolutePath() + File.separator;
}
public InputStream readAsset(String fileName) throws IOException {
return assets.open(fileName);
}
public InputStream readFile(String fileName) throws IOException {
return new FileInputStream(externalStoragePath + fileName);
}
public OutputStream writeFile(String fileName) throws IOException {
return new FileOutputStream(externalStoragePath + fileName);
}
public SharedPreferences getPreferences() {
return PreferenceManager.getDefaultSharedPreferences(context);
}
}
一切都很简单。我们实现了 FileIO 接口,存储了 Context 实例,它是 Android 中几乎所有东西的网关,存储了一个 AssetManager,它是我们从上下文中提取的,存储了外部存储的路径,并基于该路径实现了四个方法。最后,我们传递任何抛出的 IOExceptions,这样我们就知道调用方是否有任何异常。
我们的游戏接口实现将保存这个类的一个实例,并通过 Game.getFileIO()返回它。这也意味着我们的游戏实现需要通过上下文才能让 AndroidFileIO 实例工作。
请注意,我们不检查外部存储是否可用。如果它不可用,或者如果我们忘记向清单文件添加适当的权限,我们将得到一个异常,因此检查错误是隐式的。现在,我们可以进入框架的下一部分,即音频。
机器人音频、机器人声音和机器人音乐:碰撞、撞击、撞击!
在第三章中,我们为我们所有的音频需求设计了三个界面:音频、声音和音乐。Audio 负责从资源文件创建声音和音乐实例。声音可以让我们播放存储在内存中的音效,音乐可以将更大的音乐文件从磁盘传输到声卡。在第四章中,你学习了实现这个需要哪些 Android APIs。我们将从 AndroidAudio 的实现开始,如清单 5-2 所示,并在适当的地方穿插解释文本。
清单 5-2 。**【AndroidAudio.java】;实现音频接口
package com.badlogic.androidgames.framework.impl;
import java.io.IOException;
import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.media.AudioManager;
import android.media.SoundPool;
import com.badlogic.androidgames.framework.Audio;
import com.badlogic.androidgames.framework.Music;
import com.badlogic.androidgames.framework.Sound;
public class AndroidAudio implements Audio {
AssetManager assets;
SoundPool soundPool;
AndroidAudio 实现有一个 AssetManager 和一个 SoundPool 实例。在调用 AndroidAudio.newSound()时,AssetManager 是将声音效果从素材文件加载到 SoundPool 中所必需的。AndroidAudio 实例还管理 SoundPool。
public AndroidAudio(Activity activity) {
activity.setVolumeControlStream(AudioManager.*STREAM_MUSIC*);
this.assets = activity.getAssets();
this.soundPool = new SoundPool(20, AudioManager.*STREAM_MUSIC*, 0);
}
我们在构造函数中传递游戏的 Activity 有两个原因:它允许我们设置媒体流的音量控制(我们总是希望这样做),它给了我们一个 AssetManager 实例,我们很乐意将它存储在相应的类成员中。SoundPool 配置为并行播放 20 种音效,这足以满足我们的需求。
public Music newMusic(String filename) {
try {
AssetFileDescriptor assetDescriptor = assets.openFd(filename);
return new AndroidMusic(assetDescriptor);
}catch (IOException e) {
throw new RuntimeException("Couldn't load music '" + filename + "'");
}
}
newMusic()方法创建一个新的 AndroidMusic 实例。该类的构造函数接受一个 AssetFileDescriptor,用它来创建一个内部 MediaPlayer(稍后将详细介绍)。如果出现问题,AssetManager.openFd()方法会抛出 IOException。我们捕获它并将其作为 RuntimeException 重新抛出。为什么不把 IOException 交给调用者呢?首先,它会使调用代码相当混乱,所以我们宁愿抛出一个不必显式捕获的 RuntimeException。其次,我们从一个素材文件中加载音乐。只有当我们忘记将音乐文件添加到 assets/directory 中,或者音乐文件包含错误的字节时,它才会失败。错误字节构成了不可恢复的错误,因为我们需要音乐实例来使我们的游戏正常运行。为了避免这种情况发生,我们在游戏框架中的更多地方抛出 RuntimeExceptions 而不是 checked exceptions。
public Sound newSound(String filename) {
try {
AssetFileDescriptor assetDescriptor = assets.openFd(filename);
int soundId = soundPool.load(assetDescriptor, 0);
return new AndroidSound(soundPool, soundId);
}catch (IOException e) {
throw new RuntimeException("Couldn't load sound '" + filename + "'");
}
}
}
最后,newSound()方法将资源中的声音效果加载到 SoundPool 中,并返回一个 AndroidSound 实例。该实例的构造函数获取一个 SoundPool 和 SoundPool 分配给它的音效 ID。同样,我们捕捉任何 IOException 并将其作为未检查的 RuntimeException 重新抛出。
注意我们不会以任何方式释放 SoundPool。原因是总会有一个游戏实例拥有一个音频实例,而音频实例拥有一个 SoundPool 实例。因此,只要活动(以及我们的游戏)存在,SoundPool 实例就将存在。活动一结束就会自动销毁。
接下来,我们将讨论 AndroidSound 类,它实现了声音接口。清单 5-3 展示了它的实现。
***清单 5-3 。***使用 AndroidSound.java 实现声音接口
package com.badlogic.androidgames.framework.impl;
import android.media.SoundPool;
import com.badlogic.androidgames.framework.Sound;
public class AndroidSoundimplements Sound {
int soundId;
SoundPool soundPool;
public AndroidSound(SoundPool soundPool, int soundId) {
this.soundId = soundId;
this.soundPool = soundPool;
}
public void play(float volume) {
soundPool.play(soundId, volume, volume, 0, 0, 1);
}
public void dispose() {
soundPool.unload(soundId);
}
}
这里没有惊喜。通过 play()和 dispose()方法,我们简单地存储 SoundPool 和加载的声音效果的 ID,以便以后播放和处理。感谢 Android API,没有比这更简单的了。
最后,我们要实现 AndroidAudio.newMusic()返回的 AndroidMusic 类。清单 5-4 显示了这个类的代码,看起来比以前要复杂一些。这是由于 MediaPlayer 使用的状态机,如果我们在某些状态下调用方法,它会不断抛出异常。请注意,清单再次被分解,并在适当的地方插入了注释。
清单 5-4 。**【AndroidMusic.java】;实现音乐界面
package com.badlogic.androidgames.framework.impl;
import java.io.IOException;
import android.content.res.AssetFileDescriptor;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import com.badlogic.androidgames.framework.Music;
public class AndroidMusic implements Music, OnCompletionListener {
MediaPlayer mediaPlayer;
boolean isPrepared = false ;
AndroidMusic 类存储一个 MediaPlayer 实例和一个名为 isPrepared 的布尔值。记住,我们只能在 MediaPlayer 准备好的情况下调用 media player . start()/stop()/pause()。该成员帮助我们跟踪 MediaPlayer 的状态。
AndroidMusic 类实现了 Music 接口和 OnCompletionListener 接口。在第四章的中,我们简单地将这个界面定义为一种通知我们自己媒体播放器何时停止播放音乐文件的方式。如果发生这种情况,MediaPlayer 需要在我们调用任何其他方法之前再次准备好。on completion listener . on completion()方法可能在单独的线程中调用,由于我们在此方法中设置了 isPrepared 成员,因此我们必须确保它不会被并发修改。
public AndroidMusic(AssetFileDescriptor assetDescriptor) {
mediaPlayer = new MediaPlayer();
try {
mediaPlayer.setDataSource(assetDescriptor.getFileDescriptor(),
assetDescriptor.getStartOffset(),
assetDescriptor.getLength());
mediaPlayer.prepare();
isPrepared = true ;
mediaPlayer.setOnCompletionListener(this );
}catch (Exception e) {
throw new RuntimeException("Couldn't load music");
}
}
在构造函数中,我们从传入的 AssetFileDescriptor 创建并准备 MediaPlayer,我们设置 isPrepared 标志,并将 AndroidMusic 实例注册为 MediaPlayer 的 OnCompletionListener。如果出现任何问题,我们再次抛出一个未检查的 RuntimeException。
public void dispose() {
if (mediaPlayer.isPlaying())
mediaPlayer.stop();
mediaPlayer.release();
}
dispose()方法检查 MediaPlayer 是否还在播放,如果是,就停止播放。否则,对 MediaPlayer.release()的调用将引发 RuntimeException。
public boolean isLooping() {
return mediaPlayer.isLooping();
}
public boolean isPlaying() {
return mediaPlayer.isPlaying();
}
public boolean isStopped() {
return !isPrepared;
}
方法 isLooping()、isPlaying()和 isStopped()非常简单。MediaPlayer 提供的前两种使用方法;最后一个使用 isPrepared 标志,它指示 MediaPlayer 是否停止。这是 MediaPlayer . is play()不一定要告诉我们的,因为如果 media player 暂停但没有停止,它会返回 false。
public void pause() {
if (mediaPlayer.isPlaying())
mediaPlayer.pause();
}
pause()方法只是检查 MediaPlayer 实例是否正在播放,如果正在播放,就调用它的 pause()方法。
public void play() {
if (mediaPlayer.isPlaying())
return ;
try {
synchronized (this ) {
if (!isPrepared)
mediaPlayer.prepare();
mediaPlayer.start();
}
}catch (IllegalStateException e) {
e.printStackTrace();
}catch (IOException e) {
e.printStackTrace();
}
}
play()方法稍微复杂一些。如果我们已经在玩了,我们就从函数返回。接下来,我们有一个强大的尝试。。。catch 块,我们在其中检查 MediaPlayer 是否已经根据我们的标志准备好;如果需要,我们会准备的。如果一切顺利,我们调用 MediaPlayer.start()方法,这将开始播放。这是在 synchronized 块中进行的,因为我们使用的是 isPrepared 标志,该标志可能会在单独的线程上设置,因为我们实现的是 OnCompletionListener 接口。万一出错,我们抛出一个未检查的 RuntimeException。
public void setLooping(boolean isLooping) {
mediaPlayer.setLooping(isLooping);
}
public void setVolume(float volume) {
mediaPlayer.setVolume(volume, volume);
}
setLooping()和 setVolume()方法可以在 MediaPlayer 的任何状态下调用,并委托给相应的 MediaPlayer 方法。
public void stop() {
mediaPlayer.stop();
synchronized (this ) {
isPrepared = false ;
}
}
stop()方法停止 MediaPlayer 并在同步块中设置 isPrepared 标志。
public void onCompletion(MediaPlayer player) {
synchronized (this ) {
isPrepared = false ;
}
}
}
最后,还有由 AndroidMusic 类实现的 on completion listener . on completion()方法。它所做的只是在 synchronized 块中设置 isPrepared 标志,这样其他方法就不会突然抛出异常。接下来,我们将继续学习与输入相关的类。
机器人输入和加速度处理器
使用一些方便的方法,我们在第三章中设计的输入界面允许我们在轮询和事件模式下访问加速度计、触摸屏和键盘。将该接口实现的所有代码放在一个文件中的想法有点讨厌,所以我们将所有输入事件处理外包给处理程序类。输入实现将使用这些处理程序来假装它实际上正在执行所有的工作。
加速器手柄:哪边朝上?
让我们从所有处理器中最简单的开始,加速度计处理器。清单 5-5 显示了它的代码。
清单 5-5 。**【AccelerometerHandler.java】;执行所有加速度计处理
package com.badlogic.androidgames.framework.impl;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
public class AccelerometerHandler implements SensorEventListener {
float accelX;
float accelY;
float accelZ;
public AccelerometerHandler(Context context) {
SensorManager manager = (SensorManager) context
.getSystemService(Context.*SENSOR_SERVICE*);
if (manager.getSensorList(Sensor.*TYPE_ACCELEROMETER*).size() ! = 0) {
Sensor accelerometer = manager.getSensorList(
Sensor.*TYPE_ACCELEROMETER*).get(0);
manager.registerListener(this , accelerometer,
SensorManager.*SENSOR_DELAY_GAME*);
}
}
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// nothing to do here
}
public void onSensorChanged(SensorEvent event) {
accelX = event.values[0];
accelY = event.values[1];
accelZ = event.values[2];
}
public float getAccelX() {
return accelX;
}
public float getAccelY() {
return accelY;
}
public float getAccelZ() {
return accelZ;
}
}
不出所料,该类实现了我们在第四章中使用的 SensorEventListener 接口。该类通过保存三个加速度计轴上的加速度来存储三个成员。
构造函数获取一个上下文,从中获取一个 SensorManager 实例来设置事件侦听。剩下的代码相当于我们在第四章中所做的。请注意,如果没有安装加速度计,处理器将很乐意在其整个生命周期内在所有轴上返回零加速度。因此,我们不需要任何额外的错误检查或异常抛出代码。
接下来的两个方法,onAccuracyChanged()和 onSensorChanged(),应该很熟悉。在第一种方法中,我们什么都不做,所以没有什么可报告的。在第二个示例中,我们从提供的 SensorEvent 中获取加速度计值,并将它们存储在处理程序的成员中。最后三种方法只是返回每个轴的当前加速度。
注意,我们不需要在这里执行任何同步,即使可能在不同的线程中调用 onSensorChanged()方法。Java 内存模型保证对 Boolean、int 或 byte 等基本类型的读写是原子的。在这种情况下,依靠这个事实是可以的,因为我们没有做任何比赋值更复杂的事情。如果不是这种情况,我们就需要适当的同步(例如,如果我们对 onSensorChanged()方法中的成员变量做了一些事情)。
CompassHandler
只是为了好玩,我们将提供一个例子,类似于加速度处理器,但是这一次我们将给出罗盘值以及手机的俯仰和滚动,如清单 5-6 所示。我们称罗盘值为偏航,因为这是一个标准的方位术语,很好地定义了我们看到的值。
Android 通过相同的接口处理所有传感器,因此这个例子向您展示了如何应对这种情况。列表 5-6 与之前的加速度计示例之间的唯一区别是传感器类型变为 TYPE_ORIENTATION,并且字段从 accel 重命名为 yaw、pitch 和 roll。否则,它以同样的方式工作,您可以很容易地将这些代码作为控制处理程序交换到游戏中!
清单 5-6 。**【CompassHandler.java】;执行所有罗盘操作
package com.badlogic.androidgames.framework.impl;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
public class CompassHandler implements SensorEventListener {
float yaw;
float pitch;
float roll;
public CompassHandler(Context context) {
SensorManager manager = (SensorManager) context
.getSystemService(Context.SENSOR_SERVICE);
if (manager.getSensorList(Sensor.TYPE_ORIENTATION).size() ! = 0) {
Sensor compass = manager.getDefaultSensor(Sensor.TYPE_ORIENTATION);
manager.registerListener(this , compass,
SensorManager.SENSOR_DELAY_GAME);
}
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// nothing to do here
}
@Override
public void onSensorChanged(SensorEvent event) {
yaw = event.values[0];
pitch = event.values[1];
roll = event.values[2];
}
public float getYaw() {
return yaw;
}
public float getPitch() {
return pitch;
}
public float getRoll() {
return roll;
}
}
我们不会在本书的任何游戏中使用指南针,但是如果你打算重用我们开发的框架,这个类可能会派上用场。
池类:因为复用对你有好处!
作为 Android 开发者,我们可能遇到的最糟糕的事情是什么?世界停止垃圾收集!如果你查看第三章中的输入接口定义,你会发现 getTouchEvents()和 getKeyEvents()方法。这些方法返回 TouchEvent 和 KeyEvent 列表。在我们的键盘和触摸事件处理程序中,我们不断地创建这两个类的实例,并将它们存储在处理程序内部的列表中。当按下一个键或手指触摸屏幕时,Android 输入系统会触发许多这样的事件,因此我们不断创建新的实例,由垃圾收集器在很短的时间间隔内收集。为了避免这种情况,我们实现了一个叫做实例池的概念。我们简单地重用以前创建的实例,而不是重复创建一个类的新实例。Pool 类是实现该行为的一种便捷方式。让我们看看它在清单 5-7 中的代码,它被再次分解,包含适当的注释。
清单 5-7 。**【Pool.java】;玩好垃圾收集器
package com.badlogic.androidgames.framework;
import java.util.ArrayList;
import java.util.List;
public class Pool < T > {
这里是泛型:首先要认识到这是一个泛型类,很像 ArrayList 或 HashMap 之类的集合类。泛型允许我们在池中存储任何类型的对象,而不必不断地进行类型转换。那么 Pool 类是做什么的呢?
public interface PoolObjectFactory < T > {
public T createObject();
}
首先定义的是一个名为 PoolObjectFactory 的接口,它也是通用的。它有一个方法 createObject(),该方法将返回一个具有 Pool/PoolObjectFactory 实例的通用类型的新对象。
private final List < T > freeObjects;
private final PoolObjectFactory < T > factory;
private final int maxSize;
Pool 类有三个成员。其中包括一个用于存储池化对象的 ArrayList、一个用于生成由类保存的类型的新实例的 PoolObjectFactory,以及一个存储池可以保存的最大对象数的成员。最后一点是必需的,这样我们的池就不会无限增长;否则,我们可能会遇到内存不足的异常。
public Pool(PoolObjectFactory < T > factory, int maxSize) {
this.factory = factory;
this.maxSize = maxSize;
this.freeObjects = new ArrayList < T > (maxSize);
}
Pool 类的构造函数采用 PoolObjectFactory 和它应该存储的最大对象数。我们将这两个参数存储在各自的成员中,并用设置为最大对象数的容量实例化一个新的 ArrayList。
public T newObject() {
T object = null ;
if (freeObjects.isEmpty())
object = factory.createObject();
else
object = freeObjects.remove(freeObjects.size() - 1);
return object;
}
newObject()方法负责通过 PoolObjectFactory.newObject()方法向我们传递一个池持有的类型的全新实例,或者在 freeObjectsArrayList 中有池实例的情况下返回一个池实例。如果我们使用这个方法,只要池中有一些存储在 freeObjects 列表中的对象,我们就可以得到回收的对象。否则,该方法通过工厂创建一个新的。
public void free(T object) {
if (freeObjects.size() < maxSize)
freeObjects.add(object);
}
}
free()方法允许我们重新插入不再使用的对象。如果对象还没有填满,它只是将对象插入到 freeObjects 列表中。如果列表已满,则不会添加该对象,它很可能会在垃圾收集器下次执行时被消耗掉。
那么,我们如何使用这个类呢?我们将结合触摸事件来看看 Pool 类的一些伪代码用法。
PoolObjectFactory <TouchEvent> factory = new PoolObjectFactory <TouchEvent> () {
@Override
public TouchEvent createObject() {
return new TouchEvent();
}
};
Pool <TouchEvent> touchEventPool = new Pool <TouchEvent> (factory, 50);
TouchEvent touchEvent = touchEventPool.newObject();
. . . do something here . . .
touchEventPool.free(touchEvent);
首先,我们定义一个创建 TouchEvent 实例的 PoolObjectFactory。接下来,我们实例化这个池,告诉它使用我们的工厂,它应该最多存储 50 个 TouchEvents。当我们需要池中的新 TouchEvent 时,我们调用池的 newObject()方法。最初,池是空的,因此它将要求工厂创建一个全新的 TouchEvent 实例。当我们不再需要 TouchEvent 时,我们通过调用池的 free()方法将其重新插入池中。下一次我们调用 newObject()方法时,我们将获得相同的 TouchEvent 实例并回收它,以避免垃圾收集器出现问题。这个类在几个地方很有用。请注意,当从池中取出重用的对象时,必须小心地完全重新初始化它们。
键盘处理程序:上,上,下,下,左,右。。。
键盘处理程序必须完成几项任务。首先,它必须与接收键盘事件的视图相连接。接下来,它必须为轮询存储每个键的当前状态。它还必须保留一个我们在第三章中为基于事件的输入处理设计的 KeyEvent 实例列表。最后,它必须正确地同步一切,因为它将在 UI 线程上接收事件,同时从我们的主游戏循环中轮询,这是在不同的线程上执行的。这是很大的工作量!作为复习,我们将向您展示我们在第三章的中定义的作为输入接口一部分的 KeyEvent 类。
public static class KeyEvent {
public static final int *KEY_DOWN* = 0;
public static final int *KEY_UP* = 1;
public int type;
public int keyCode;
public char keyChar;
}
这个类简单地定义了两个常量,这两个常量对键事件类型以及三个成员进行编码,同时保存事件的类型、键代码和 Unicode 字符。这样,我们就可以实现我们的处理程序了。
清单 5-8 显示了使用之前讨论的 Android APIs 和我们新的 Pool 类实现处理程序。这个列表被注释打断了。
***清单 5-8 。***keyboard handler . Java:从 2010 年开始处理按键
package com.badlogic.androidgames.framework.impl;
import java.util.ArrayList;
import java.util.List;
import android.view.View;
import android.view.View.OnKeyListener;
import com.badlogic.androidgames.framework.Input.KeyEvent;
import com.badlogic.androidgames.framework.Pool;
import com.badlogic.androidgames.framework.Pool.PoolObjectFactory;
public class KeyboardHandler implements OnKeyListener {
boolean [] pressedKeys = new boolean [128];
Pool <KeyEvent> keyEventPool;
List <KeyEvent> keyEventsBuffer = new ArrayList <KeyEvent> ();
List <KeyEvent> keyEvents = new ArrayList <KeyEvent> ();
KeyboardHandler 类实现 OnKeyListener 接口,以便它可以从视图接收按键事件。成员是下一个。
第一个成员是一个包含 128 个布尔值的数组。我们将每个键的当前状态(按下与否)存储在这个数组中。它由钥匙的钥匙代码索引。幸运的是,Android . view . keyevent . key code _ XXX 常量(编码键码)都在 0 到 127 之间,因此我们可以将它们存储在垃圾收集器友好的形式中。注意,不幸的是,我们的 KeyEvent 类与 Android KeyEvent 类同名,后者的实例被传递给我们的 OnKeyEventListener.onKeyEvent()方法。这种轻微的混淆仅限于这个处理程序代码。因为对于一个关键事件来说,没有比“关键事件”更好的名字了,所以我们选择忍受这种短暂的混乱。
下一个成员是保存我们的 KeyEvent 类的实例的池。我们不想让垃圾收集器生气,所以我们回收我们创建的所有 KeyEvent 对象。
第三个成员存储我们的游戏尚未使用的 KeyEvent 实例。每当我们在 UI 线程上获得一个新的按键事件,我们就把它添加到这个列表中。
最后一个成员存储我们通过调用 KeyboardHandler.getKeyEvents()返回的 KeyEvents。在接下来的章节中,我们将会看到为什么我们必须对关键事件进行双缓冲。
public KeyboardHandler(View view) {
PoolObjectFactory <KeyEvent> factory = new PoolObjectFactory <KeyEvent> () {
public KeyEvent createObject() {
return new KeyEvent();
}
};
keyEventPool = new Pool < KeyEvent > (factory, 100);
view.setOnKeyListener(this );
view.setFocusableInTouchMode(true );
view.requestFocus();
}
该构造函数有一个参数,由我们希望从其接收按键事件的视图组成。我们使用适当的 PoolObjectFactory 创建池实例,将处理程序注册为视图的 OnKeyListener,最后,通过使视图成为焦点视图来确保视图将接收关键事件。
public boolean onKey(View v, int keyCode, android.view.KeyEvent event) {
if (event.getAction() == android.view.KeyEvent.*ACTION_MULTIPLE*)
return false ;
synchronized (this ) {
KeyEvent keyEvent = keyEventPool.newObject();
keyEvent.keyCode = keyCode;
keyEvent.keyChar = (char ) event.getUnicodeChar();
if (event.getAction() == android.view.KeyEvent.*ACTION_DOWN*) {
keyEvent.type = KeyEvent.*KEY_DOWN*;
if (keyCode > 0 && keyCode < 127)
pressedKeys[keyCode] = true ;
}
if (event.getAction() == android.view.KeyEvent.*ACTION_UP*) {
keyEvent.type = KeyEvent.*KEY_UP*;
if (keyCode > 0 && keyCode < 127)
pressedKeys[keyCode] = false ;
}
keyEventsBuffer.add(keyEvent);
}
return false ;
}
接下来,我们将讨论 OnKeyListener.onKey()接口方法的实现,每次视图接收到新的按键事件时都会调用该方法。我们从忽略任何编码按键事件的(Android)按键事件开始。动作 _ 多个事件。这些与我们的上下文无关。这后面是一个同步块。请记住,事件在 UI 线程上接收,在主循环线程上读取,因此我们必须确保没有成员被并行访问。
在 synchronized 块中,我们首先从池中获取一个 KeyEvent 实例(我们的 KeyEvent 实现的实例)。这将使我们获得一个回收的实例或一个全新的实例,这取决于池的状态。接下来,我们根据传递给该方法的 Android KeyEvent 的内容设置 KeyEvent 的 keyCode 和 keyChar 成员。然后,我们解码 Android KeyEvent 类型,并相应地设置我们的 KeyEvent 的类型以及 pressedKey 数组中的元素。最后,我们将 KeyEvent 添加到前面定义的 keyEventBuffer 列表中。
public boolean isKeyPressed(int keyCode) {
if (keyCode < 0 || keyCode > 127)
return false ;
return pressedKeys[keyCode];
}
我们处理程序的下一个方法是 isKeyPressed()方法,它实现了 Input.isKeyPressed()的语义。首先,我们传入一个指定键码的整数(Android KeyEvent 之一。KEYCODE_XXX 常量)并返回该键是否被按下。我们通过在一些范围检查之后在 pressedKey 数组中查找键的状态来做到这一点。记住,我们在前面的方法中设置了这个数组的元素,这个方法在 UI 线程中被调用。因为我们又在处理基本类型,所以不需要同步。
public List <KeyEvent> getKeyEvents() {
synchronized (this ) {
int len = keyEvents.size();
for (int i = 0; i < len; i++) {
keyEventPool.free(keyEvents.get(i));
}
keyEvents.clear();
keyEvents.addAll(keyEventsBuffer);
keyEventsBuffer.clear();
return keyEvents;
}
}
}
我们的处理程序的最后一个方法称为 getKeyEvents(),它实现了 Input.getKeyEvents()方法的语义。同样,我们从一个同步块开始,记住这个方法将从不同的线程调用。
接下来,我们遍历 keyEvents 数组,并将其所有的 KeyEvents 插入到我们的池中。记住,我们在 UI 线程的 onKey()方法中从池中获取实例。在这里,我们将它们重新插入池中。但是 keyEvents 列表不是空的吗?是的,但只是在我们第一次调用那个方法的时候。要理解为什么,你必须掌握剩下的方法。
在我们神秘的池插入循环之后,我们清除 keyEvents 列表并用 keyEventsBuffer 列表中的事件填充它。最后,我们清除 keyEventsBuffer 列表,并将新填充的 keyEvents 列表返回给调用者。这里发生了什么事?
我们将用一个简单的例子来说明这一点。首先,我们将检查每次新事件到达 UI 线程或游戏在主线程中获取事件时,keyEvents 和 keyEventsBuffer 列表以及我们的池会发生什么:
UI thread: onKey() ->
keyEvents = { }, keyEventsBuffer = {KeyEvent1}, pool = { }
Main thread: getKeyEvents() ->
keyEvents = {KeyEvent1}, keyEventsBuffer = { }, pool { }
UI thread: onKey() ->
keyEvents = {KeyEvent1}, keyEventsBuffer = {KeyEvent2}, pool { }
Main thread: getKeyEvents() ->
keyEvents = {KeyEvent2}, keyEventsBuffer = { }, pool = {KeyEvent1}
UI thread: onKey() ->
keyEvents = {KeyEvent2}、keyeventsbuffer = { keyevent 1 }、pool = { }
- 我们在 UI 线程中得到一个新事件。池中还没有任何东西,所以创建了一个新的 KeyEvent 实例(KeyEvent1)并将其插入到 keyEventsBuffer 列表中。
- 我们在主线程上调用 getKeyEvents()。getKeyEvents()从 keyEventsBuffer 列表中获取 KeyEvent1,并将其放入返回给调用者的 KeyEvents 列表中。
- 我们在 UI 线程上得到另一个事件。我们在池中仍然什么都没有,所以创建了一个新的 KeyEvent 实例(KeyEvent2)并将其插入到 keyEventsBuffer 列表中。
- 主线程再次调用 getKeyEvents()。现在,有趣的事情发生了。进入该方法后,keyEvents 列表仍然保存 KeyEvent1。插入循环会将事件放入我们的池中。然后,它清除 keyEvents 列表并将任何 KeyEvent 插入到 keyEventsBuffer 中,在本例中为 KeyEvent2。我们刚刚回收了一个关键事件。
- 另一个关键事件到达 UI 线程。这一次,我们的池中有一个免费的 KeyEvent,我们很乐意重用它。令人难以置信的是,没有垃圾收集!
这种机制有一个警告,即我们必须频繁调用 KeyboardHandler.getKeyEvents(),否则 KeyEvents 列表会很快填满,并且没有对象返回到池中。只要我们记住这一点,问题是可以避免的。
触摸处理器
现在是时候考虑碎片化了。在第四章中,我们透露了多点触控仅在高于 1.6 的 Android 版本上受支持。我们在多点触摸代码中使用的所有好的常量(例如,MotionEvent。ACTION_POINTER_ID_MASK)在 Android 1.5 或 1.6 上对我们不可用。如果我们将项目的构建目标设置为具有该 API 的 Android 版本,我们可以在代码中使用它们;然而,该应用将在任何运行 Android 1.5 或 1.6 的设备上崩溃。我们希望我们的游戏可以在目前所有可用的 Android 版本上运行,那么我们如何解决这个问题呢?
我们使用了一个简单的技巧。我们编写两个处理程序,一个使用 Android 1.5 中的单触 API,另一个使用 Android 2.0 及以上版本中的多触 API。只要我们不在低于 2.0 版本的 Android 设备上执行多点触摸处理程序代码,这是安全的。VM 不会加载代码,也不会连续抛出异常。我们需要做的就是找出设备运行的 Android 版本,并实例化适当的处理程序。当我们讨论 AndroidInput 类时,您将看到这是如何工作的。现在,让我们把注意力集中在这两个处理程序上。
触摸处理器接口
为了互换使用我们的两个处理程序类,我们需要定义一个公共接口。清单 5-9 展示了 TouchHandler 接口。
***清单 5-9 。***TouchHandler.java,将在 Android 1.5 和 1.6 上实现
package com.badlogic.androidgames.framework.impl;
import java.util.List;
import android.view.View.OnTouchListener;
import com.badlogic.androidgames.framework.Input.TouchEvent;
public interface TouchHandlerextends OnTouchListener {
public boolean isTouchDown(int pointer);
public int getTouchX(int pointer);
public int getTouchY(int pointer);
public List <TouchEvent> getTouchEvents();
}
所有 TouchHandlers 都必须实现 OnTouchListener 接口,该接口用于向视图注册处理程序。接口的方法对应于第三章中定义的输入接口的相应方法。前三个用于轮询特定指针 ID 的状态,最后一个用于获取用来执行基于事件的输入处理的触摸事件。注意,轮询方法采用指针 id,它可以是任何数字,由触摸事件给出。
SingleTouchHandler 类
在我们的单触处理程序中,我们忽略除零以外的任何 id。概括地说,我们将回忆一下在第三章中定义的 TouchEvent 类,它是输入接口的一部分。
public static class TouchEvent {
public static final int *TOUCH_DOWN* = 0;
public static final int *TOUCH_UP* = 1;
public static final int *TOUCH_DRAGGED* = 2;
public int type;
public int x, y;
public int pointer;
}
像 KeyEvent 类一样,TouchEvent 类定义了两个常数,它们反映了触摸事件的类型,以及视图坐标系中的 x 和 y 坐标和指针 ID。清单 5-10 展示了 Android 1.5 和 1.6 的 TouchHandler 接口的实现,通过注释进行了分解。
清单 5-10 。**【SingleTouchHandler.java】;单点触控效果不错,多点触控效果不太好
package com.badlogic.androidgames.framework.impl;
import java.util.ArrayList;
import java.util.List;
import android.view.MotionEvent;
import android.view.View;
import com.badlogic.androidgames.framework.Pool;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Pool.PoolObjectFactory;
public class SingleTouchHandler implements TouchHandler {
boolean isTouched;
int touchX;
int touchY;
Pool <TouchEvent> touchEventPool;
List <TouchEvent> touchEvents = new ArrayList <TouchEvent> ();
List <TouchEvent> touchEventsBuffer = new ArrayList <TouchEvent> ();
float scaleX;
float scaleY;
我们首先让类实现 TouchHandler 接口,这也意味着我们必须实现 OnTouchListener 接口。接下来,我们有三个成员存储一个手指的触摸屏的当前状态,后面是一个池和两个保存触摸事件的列表。这与 KeyboardHandler 中的相同。我们还有两个成员,scaleX 和 scaleY。我们将在下面的章节中解决这些问题,并使用它们来处理不同的屏幕分辨率。
注意当然,我们可以通过从一个基类派生 KeyboardHandler 和 SingleTouchHandler 来处理关于池和同步的所有问题,从而使这变得更加优雅。然而,这会使解释更加复杂,因此,我们将编写多几行代码。
public SingleTouchHandler(View view, float scaleX, float scaleY) {
PoolObjectFactory <TouchEvent> factory = new PoolObjectFactory <TouchEvent> () {
@Override
public TouchEvent createObject() {
return new TouchEvent();
}
};
touchEventPool = new Pool <TouchEvent> (factory, 100);
view.setOnTouchListener(this );
this.scaleX = scaleX;
this.scaleY = scaleY;
}
在构造函数中,我们将处理程序注册为 OnTouchListener,并设置用于回收 TouchEvents 的池。我们还存储传递给构造函数的 scaleX 和 scaleY 参数(暂时忽略它们)。
public boolean onTouch(View v, MotionEvent event) {
synchronized (this ) {
TouchEvent touchEvent = touchEventPool.newObject();
switch (event.getAction()) {
case MotionEvent.*ACTION_DOWN*:
touchEvent.type = TouchEvent.*TOUCH_DOWN*;
isTouched = true ;
break ;
case MotionEvent.*ACTION_MOVE*:
touchEvent.type = TouchEvent.*TOUCH_DRAGGED*;
isTouched = true ;
break ;
case MotionEvent.*ACTION_CANCEL*:
case MotionEvent.*ACTION_UP*:
touchEvent.type = TouchEvent.*TOUCH_UP*;
isTouched = false ;
break ;
}
touchEvent.x = touchX = (int )(event.getX() * scaleX);
touchEvent.y = touchY = (int )(event.getY() * scaleY);
touchEventsBuffer.add(touchEvent);
return true ;
}
}
onTouch()方法实现了与我们的 KeyboardHandler 的 onKey()方法相同的结果;唯一的区别是现在我们处理触摸事件而不是按键事件。我们已经知道了所有的同步、池和运动事件处理。唯一有趣的是,我们将报告的触摸事件的 x 和 y 坐标乘以 scaleX 和 scaleY。记住这一点很重要,因为我们将在接下来的部分中回到这一点。
public boolean isTouchDown(int pointer) {
synchronized (this ) {
if (pointer == 0)
return isTouched;
else
return false ;
}
}
public int getTouchX(int pointer) {
synchronized (this ) {
return touchX;
}
}
public int getTouchY(int pointer) {
synchronized (this ) {
return touchY;
}
}
isTouchDown()、getTouchX()和 getTouchY()方法允许我们根据在 onTouch()方法中设置的成员来轮询触摸屏的状态。唯一值得注意的是,它们只返回指针 ID 值为零的有用数据,因为这个类只支持单点触摸屏。
public List <TouchEvent> getTouchEvents() {
synchronized (this ) {
int len = touchEvents.size();
for (int i = 0; i < len; i++ )
touchEventPool.free(touchEvents.get(i));
touchEvents.clear();
touchEvents.addAll(touchEventsBuffer);
touchEventsBuffer.clear();
return touchEvents;
}
}
}
最后一个方法 singletouchhandler . gettouchevents()应该为您所熟悉,它类似于 KeyboardHandler.getKeyEvents()方法。记住我们经常调用这个方法,这样 touchEvents 列表就不会填满。
多触点手柄
对于多点触摸处理,我们使用一个名为 MultiTouchHandler 的类,如清单 5-11 所示。
清单 5-11 。**【MultiTouchHandler.java】(更多相同)
package com.badlogic.androidgames.framework.impl;
import java.util.ArrayList;
import java.util.List;
import android.view.MotionEvent;
import android.view.View;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Pool;
import com.badlogic.androidgames.framework.Pool.PoolObjectFactory;
@TargetApi(5)
public class MultiTouchHandler implements TouchHandler {
private static final int *MAX_TOUCHPOINTS* = 10;
boolean [] isTouched = new boolean [*MAX_TOUCHPOINTS*];
int [] touchX = new int [*MAX_TOUCHPOINTS*];
int [] touchY = new int [*MAX_TOUCHPOINTS*];
int [] id = new int [*MAX_TOUCHPOINTS*];
Pool <TouchEvent> touchEventPool;
List <TouchEvent> touchEvents = new ArrayList <TouchEvent> ();
List <TouchEvent> touchEventsBuffer = new ArrayList <TouchEvent> ();
float scaleX;
float scaleY;
public MultiTouchHandler(View view, float scaleX, float scaleY) {
PoolObjectFactory <TouchEvent> factory = new PoolObjectFactory <TouchEvent> () {
public TouchEvent createObject() {
return new TouchEvent();
}
};
touchEventPool = new Pool <TouchEvent> (factory, 100);
view.setOnTouchListener(this );
this.scaleX = scaleX;
this.scaleY = scaleY;
}
public boolean onTouch(View v, MotionEvent event) {
synchronized (this ) {
int action = event.getAction() & MotionEvent.*ACTION_MASK*;
int pointerIndex = (event.getAction() & MotionEvent.*ACTION_POINTER_ID_MASK*) > > MotionEvent.*ACTION_POINTER_ID_SHIFT*;
int pointerCount = event.getPointerCount();
TouchEvent touchEvent;
for (int i = 0; i < *MAX_TOUCHPOINTS*; i++) {
if (i >= pointerCount) {
isTouched[i] = false ;
id[i] = -1;
continue ;
}
int pointerId = event.getPointerId(i);
if (event.getAction() != MotionEvent.*ACTION_MOVE*&& i != pointerIndex) {
// if it's an up/down/cancel/out event, mask the id to see if we should process it for this touch
// point
continue ;
}
switch (action) {
case MotionEvent.*ACTION_DOWN*:
case MotionEvent.*ACTION_POINTER_DOWN*:
touchEvent = touchEventPool.newObject();
touchEvent.type = TouchEvent.*TOUCH_DOWN*;
touchEvent.pointer = pointerId;
touchEvent.x = touchX[i] = (int ) (event.getX(i) * scaleX);
touchEvent.y = touchY[i] = (int ) (event.getY(i) * scaleY);
isTouched[i] = true ;
id[i] = pointerId;
touchEventsBuffer.add(touchEvent);
break ;
case MotionEvent.*ACTION_UP*:
case MotionEvent.*ACTION_POINTER_UP*:
case MotionEvent.*ACTION_CANCEL*:
touchEvent = touchEventPool.newObject();
touchEvent.type = TouchEvent.*TOUCH_UP*;
touchEvent.pointer = pointerId;
touchEvent.x = touchX[i] = (int ) (event.getX(i) * scaleX);
touchEvent.y = touchY[i] = (int ) (event.getY(i) * scaleY);
isTouched[i] = false ;
id[i] = -1;
touchEventsBuffer.add(touchEvent);
break ;
case MotionEvent.*ACTION_MOVE*:
touchEvent = touchEventPool.newObject();
touchEvent.type = TouchEvent.*TOUCH_DRAGGED*;
touchEvent.pointer = pointerId;
touchEvent.x = touchX[i] = (int ) (event.getX(i) * scaleX);
touchEvent.y = touchY[i] = (int ) (event.getY(i) * scaleY);
isTouched[i] = true ;
id[i] = pointerId;
touchEventsBuffer.add(touchEvent);
break ;
}
}
return true ;
}
}
public boolean isTouchDown(int pointer) {
synchronized (this ) {
int index = getIndex(pointer);
if (index < 0 || index >=*MAX_TOUCHPOINTS*)
return false ;
else
return isTouched[index];
}
}
public int getTouchX(int pointer) {
synchronized (this ) {
int index = getIndex(pointer);
if (index < 0 || index >=*MAX_TOUCHPOINTS*)
return 0;
else
return touchX[index];
}
}
public int getTouchY(int pointer) {
synchronized (this ) {
int index = getIndex(pointer);
if (index < 0 || index >=*MAX_TOUCHPOINTS*)
return 0;
else
return touchY[index];
}
}
public List <TouchEvent> getTouchEvents() {
synchronized (this ) {
int len = touchEvents.size();
for (int i = 0; i < len; i++)
touchEventPool.free(touchEvents.get(i));
touchEvents.clear();
touchEvents.addAll(touchEventsBuffer);
touchEventsBuffer.clear();
return touchEvents;
}
}
// returns the index for a given pointerId or −1 if no index.
private int getIndex(int pointerId) {
for (int i = 0; i < *MAX_TOUCHPOINTS*; i++) {
if (id[i] == pointerId) {
return i;
}
}
return -1;
}
}
我们从另一个 TargetApi 注释开始,告诉编译器我们知道自己在做什么。在这种情况下,我们将最低 API 级别设置为 3,但是多点触摸处理程序中的代码需要 API 级别 5。如果没有这个注释,编译器会报错。
onTouch()方法看起来和我们在第四章中的测试例子一样吓人。然而,我们需要做的就是将测试代码与我们的事件池和同步结合起来,这一点我们已经详细讨论过了。与 SingleTouchHandler.onTouch()方法唯一真正的区别是,我们处理多个指针并相应地设置 TouchEvent.pointer 成员(而不是使用零值)。
轮询方法 isTouchDown()、getTouchX()和 getTouchY()看起来也应该很熟悉。我们执行一些错误检查,然后从填充到 onTouch()方法中的一个成员数组中获取相应指针索引的相应指针状态。
最后一个公共方法 getTouchEvents()与 singletouchhandler . getTouchEvents()中对应的方法完全相同。现在我们已经配备了所有这些处理程序,我们可以实现输入接口了。
类中的最后一个方法是帮助器方法,我们用它来查找指针 ID 的索引。
伟大的协调者
我们游戏框架的输入实现将我们开发的所有处理程序联系在一起。任何方法调用都被委托给相应的处理程序。这个实现唯一有趣的部分是根据设备运行的 Android 版本选择使用哪个 TouchHandler 实现。清单 5-12 显示了一个叫做 AndroidInput 的实现,并附有注释。
清单 5-12 。**【AndroidInput.java】;使用样式处理处理程序
package com.badlogic.androidgames.framework.impl;
import java.util.List;
import android.content.Context;
import android.os.Build.VERSION;
import android.view.View;
import com.badlogic.androidgames.framework.Input;
public class AndroidInput implements Input {
AccelerometerHandler accelHandler;
KeyboardHandler keyHandler;
TouchHandler touchHandler;
我们首先让这个类实现在第三章中定义的输入接口。这就引出了三个成员:AccelerometerHandler、KeyboardHandler 和 TouchHandler。
public AndroidInput(Context context, View view, float scaleX, float scaleY) {
accelHandler = new AccelerometerHandler(context);
keyHandler = new KeyboardHandler(view);
if (Integer.*parseInt*(VERSION.*SDK*) < 5)
touchHandler = new SingleTouchHandler(view, scaleX, scaleY);
else
touchHandler = new MultiTouchHandler(view, scaleX, scaleY);
}
这些成员在构造函数中初始化,构造函数接受一个上下文、一个视图以及 scaleX 和 scaleY 参数,我们可以再次忽略这些参数。AccelerometerHandler 通过 Context 参数实例化,因为 KeyboardHandler 需要传入的视图。
为了决定使用哪个 TouchHandler,我们只需检查应用运行所使用的 Android 版本。这可以使用版本来完成。SDK 字符串,是 Android API 提供的常量。不清楚为什么这是一个字符串,因为它直接编码了我们在清单文件中使用的 SDK 版本号。所以我们需要把它做成整数,以便做一些比较。第一个支持多点触摸 API 的 Android 版本是版本 2.0,对应于 SDK 版本 5。如果当前设备运行较低的 Android 版本,我们实例化 SingleTouchHandler 否则,我们使用 MultiTouchHandler。在 API 级别,这就是我们需要关心的所有碎片。当我们开始渲染 OpenGL 时,我们会遇到更多的碎片问题,但没有必要担心——这些问题很容易解决,就像 touch API 问题一样。
public boolean isKeyPressed(int keyCode) {
return keyHandler.isKeyPressed(keyCode);
}
public boolean isTouchDown(int pointer) {
return touchHandler.isTouchDown(pointer);
}
public int getTouchX(int pointer) {
return touchHandler.getTouchX(pointer);
}
public int getTouchY(int pointer) {
return touchHandler.getTouchY(pointer);
}
public float getAccelX() {
return accelHandler.getAccelX();
}
public float getAccelY() {
return accelHandler.getAccelY();
}
public float getAccelZ() {
return accelHandler.getAccelZ();
}
public List <TouchEvent> getTouchEvents() {
return touchHandler.getTouchEvents();
}
public List <KeyEvent> getKeyEvents() {
return keyHandler.getKeyEvents();
}
}
这个类的其余部分是不言自明的。每个方法调用都被委托给适当的处理程序,由它来完成实际的工作。这样,我们就完成了游戏框架的输入 API。接下来,我们将讨论图形。
AndroidGraphics 和 AndroidPixmap:双彩虹
是时候回到我们最喜爱的话题,图形编程了。在第三章中,我们定义了两个接口,分别叫做 Graphics 和 Pixmap。现在,我们将根据你在第四章中学到的东西来实现它们。然而,有一件事我们还没有考虑:如何处理不同的屏幕尺寸和分辨率。
处理不同的屏幕尺寸和分辨率
Android 从 1.6 版本开始就支持不同的屏幕分辨率。它可以处理从 240×320 像素到 1920×1080 的全高清电视分辨率。在第四章中,我们讨论了不同屏幕分辨率和物理屏幕尺寸的影响。例如,用绝对坐标和以像素为单位的尺寸绘图会产生意想不到的结果。图 5-1 显示了当我们在 480×800 和 320×480 屏幕上渲染一个左上角为(219,379)的 100×100 像素的矩形时会发生什么。
图 5-1。在 480×800 屏幕(左)和 320×480 屏幕(右)上以(219,379)绘制的 100×100 像素矩形
这种差异是有问题的,原因有二。首先,我们不能画出我们的游戏,并假设一个固定的分辨率。第二个原因更微妙:在图 5-1 中,我们假设两个屏幕具有相同的密度(即每个像素在两个设备上都具有相同的物理尺寸),但现实中很少是这样的。
密度
密度通常用每英寸像素或每厘米像素来表示(有时你会听到每英寸点数,这在技术上是不正确的)。Nexus One 拥有 480×800 像素的屏幕,物理尺寸为 8×4.8 厘米。老款 HTC Hero 的屏幕为 320×480 像素,物理尺寸为 6.5×4.5 厘米。Nexus One 的两个轴上每厘米 100 像素,Hero 的两个轴上每厘米大约 71 像素。我们可以使用下面的等式很容易地计算出每厘米的像素:
每厘米像素(x 轴上)=像素宽度/厘米宽度
或者:
每厘米像素(y 轴上)=像素高度/厘米高度
通常,我们只需要在单个轴上计算这个,因为物理像素是正方形的(它们实际上是三个像素,但我们在这里忽略它)。
以厘米为单位,一个 100×100 像素的矩形有多大?在 Nexus One 上,我们有一个 1×1 厘米的矩形,而 Hero 有一个 1.4×1.4 厘米的矩形。这是我们需要考虑的事情,例如,如果我们试图在所有屏幕尺寸上提供对普通拇指来说足够大的按钮。这个例子意味着这是一个可能带来巨大问题的主要问题;然而,通常不会。我们需要确保我们的按钮在高密度屏幕上(例如,Nexus One)有足够大的尺寸,因为它们在低密度屏幕上会自动足够大。
长宽比
纵横比是另一个需要考虑的问题。屏幕的纵横比是宽度和高度之间的比率,以像素或厘米为单位。我们可以使用下面的等式来计算纵横比:
像素纵横比=像素宽度/像素高度
或者:
物理纵横比=厘米宽度/厘米高度
这里的宽度和高度通常是指风景模式下的宽度和高度。Nexus One 的像素和物理纵横比为 1.66。英雄的像素和物理长宽比为 1.5。这是什么意思?在 Nexus One 上,相对于高度,我们在横向模式下 x 轴上的可用像素比我们在 Hero 上的可用像素多。图 5-2 用两台设备上副本岛的截图说明了这一点。
注本书采用公制。我们知道,如果您熟悉英寸和磅,这可能会带来不便。然而,由于我们将在接下来的章节中考虑一些物理问题,最好现在就习惯它,因为物理问题通常是用公制来定义的。记住 1 英寸大约是 2.54 厘米。
图 5-2。Nexus One(上)和 HTC Hero(下)上的复制岛
Nexus One 在 x 轴上显示的更多一些。然而,y 轴上的一切都是相同的。在这种情况下副本岛的创作者做了什么?
应对不同的长宽比
复制岛将作为纵横比问题的一个非常有用的例子。该游戏最初被设计为适合 480×320 像素的屏幕,包括所有的“精灵”,如机器人和医生,“世界”的瓷砖,以及 UI 元素(左下角的按钮和屏幕顶部的状态信息)。当游戏在一个英雄上渲染时,sprite 位图中的每个像素正好映射到屏幕上的一个像素。在 Nexus One 上,一切都是在渲染时按比例放大的,因此一个 sprite 的一个像素实际上占用了屏幕上的 1.5 个像素。换句话说,一个 32×32 像素的精灵在屏幕上将是 48×48 像素。使用以下公式可以很容易地计算出该比例因子:
缩放因子(x 轴上)=以像素为单位的屏幕宽度/以像素为单位的目标宽度
缩放因子(y 轴上)=以像素为单位的屏幕高度/以像素为单位的目标高度
目标宽度和高度等于图形素材设计的屏幕分辨率;在副本岛中,尺寸为 480×320 像素。对于 Nexus One,x 轴上的缩放因子为 1.66,y 轴上的缩放因子为 1.5。为什么两个轴上的比例因子不同?
这是因为两种屏幕分辨率具有不同的纵横比。如果我们简单地将 480×320 像素的图像拉伸为 800×480 像素的图像,则原始图像在 x 轴上被拉伸。对于大多数游戏来说,这无关紧要,所以我们可以简单地为特定的目标分辨率绘制图形资源,并在渲染时将它们拉伸到实际的屏幕分辨率(记住 Bitmap.drawBitmap()方法)。
然而,对于一些游戏,你可能想要使用一个更复杂的方法。图 5-3 显示了复制岛 从 480×320 放大到 800×480 像素,并覆盖了一张看起来真实的模糊图像。
图 5-3。复制岛从 480×320 像素延伸到 800×480 像素,覆盖了一个在 800×480 像素显示器上呈现的模糊图像
复制岛使用我们刚刚计算的缩放因子(1.5)在 y 轴上执行正常拉伸,但不是使用会挤压图像的 x 轴缩放因子(1.66),而是使用 y 轴缩放因子。这个技巧允许屏幕上的所有对象保持它们的纵横比。32×32 像素的精灵变成 48×48 像素,而不是 53×48 像素。但是,这也意味着我们的坐标系不再有界在(0,0)和(479,319)之间;而是从(0,0)到(533,319)。这就是为什么我们在 Nexus One 上比在 HTC Hero 上看到更多的副本岛。
但是,请注意,使用这种奇特的方法可能不适合某些游戏。例如,如果世界的大小取决于屏幕的长宽比,拥有更宽屏幕的玩家可能会有不公平的优势。像《星际争霸 2》这样的游戏就属于这种情况。最后,如果你想让整个游戏适合一个屏幕,就像《诺姆先生》一样,最好使用更简单的拉伸方法;如果我们使用第二个版本,在更宽的屏幕上会留下空白。
更简单的解决方案
副本岛的一个优势是它通过硬件加速的 OpenGL ES 来完成所有这些拉伸和缩放。到目前为止,我们只讨论了如何通过 Canvas 类绘制位图和视图,在旧版本的 Android 上,Canvas 类涉及 CPU 上缓慢的数字处理,而不涉及 GPU 上的硬件加速。
考虑到这一点,我们用我们的目标分辨率以位图实例的形式创建一个帧缓冲区,来执行一个简单的技巧。这样,当我们设计图形素材或通过代码渲染它们时,我们就不必担心实际的屏幕分辨率。相反,我们假设屏幕分辨率在所有设备上都是相同的,并且我们所有的绘制调用都通过 Canvas 实例将这个“虚拟”帧缓冲位图作为目标。当我们渲染完一个帧后,我们只需通过调用 Canvas.drawBitmap()方法将这个帧缓冲区位图绘制到我们的 SurfaceView,这允许我们绘制一个拉伸的位图。
如果我们想要使用与副本岛相同的技术,我们需要在更大的轴上调整我们的帧缓冲区的大小(即,在横向模式下在 x 轴上,在纵向模式下在 y 轴上)。我们还必须确保填充额外的像素,以避免空白。
实施
让我们总结一个工作计划中的一切:
- 我们为固定的目标分辨率设计了所有的图形资源(Nom 先生的分辨率为 320×480)。
- 我们创建一个与目标分辨率大小相同的位图,并将所有的绘图调用指向它,有效地在一个固定的坐标系中工作。
- 当我们画完一个帧后,我们画一个被拉伸到 SurfaceView 的帧缓冲位图。在屏幕分辨率较低的设备上,图像会缩小;在分辨率较高的设备上,它会放大。
- 当我们使用缩放技巧时,我们确保所有用户交互的 UI 元素对于所有的屏幕密度都足够大。我们可以在图形素材设计阶段使用实际设备的尺寸并结合前面提到的公式来实现这一点。
现在我们知道了如何处理不同的屏幕分辨率和密度,我们可以解释在前面几节中实现 SingleTouchHandler 和 MultiTouchHandler 时遇到的 scaleX 和 scaleY 变量。
我们所有的游戏代码都将使用我们固定的目标分辨率(320×480 像素)。如果我们在分辨率更高或更低的设备上接收触摸事件,这些事件的 x 和 y 坐标将在视图的坐标系中定义,而不是在我们的目标分辨率坐标系中定义。因此,有必要将坐标从其原始系统转换到我们的系统,这是基于比例因子的。为此,我们使用以下等式:
transformed touch x = real touch x * (target pixels on x axis / real pixels on x axis)
transformed touch y = real touch y * (target pixels on y axis / real pixels on y axis)
让我们计算一个简单的例子,目标分辨率为 320×480 像素,设备分辨率为 480×800 像素。如果我们触摸屏幕的中间,我们会收到一个坐标为(240,400)的事件。使用前面的两个公式,我们得到下面的方程,这些方程正好在我们的目标坐标系的中间:
transformed touch x = 240 * (320 / 480) = 160
transformed touch y = 400 * (480 / 800) = 240
让我们再做一个,假设实际分辨率为 240×320,再次触摸屏幕的中间,在(120,160):
transformed touch x = 120 * (320 / 240) = 160
transformed touch y = 160 * (480 / 320) = 240
这是双向的。如果我们将真实触摸事件坐标乘以目标因子除以真实因子,我们就不必担心转换我们实际的游戏代码。所有触摸坐标将在我们的固定目标坐标系中表示。
有了这个问题,我们就可以实现游戏框架的最后几个类了。
AndroidPixmap:为人民服务的像素
根据我们从第三章开始的 Pixmap 接口设计,实现的东西不多。清单 5-13 给出了代码。
清单 5-13 。**【AndroidPixmap.java】一个 Pixmap 实现包装位图
package com.badlogic.androidgames.framework.impl;
import android.graphics.Bitmap;
import com.badlogic.androidgames.framework.Graphics.PixmapFormat;
import com.badlogic.androidgames.framework.Pixmap;
public class AndroidPixmapimplements Pixmap {
Bitmap bitmap;
PixmapFormat format;
public AndroidPixmap(Bitmap bitmap, PixmapFormat format) {
this.bitmap = bitmap;
this.format = format;
}
public int getWidth() {
return bitmap.getWidth();
}
public int getHeight() {
return bitmap.getHeight();
}
public PixmapFormat getFormat() {
return format;
}
public void dispose() {
bitmap.recycle();
}
}
我们所需要做的就是存储我们包装的位图实例,以及它的格式,它被存储为一个 PixmapFormat 枚举值,如第三章中定义的那样。此外,我们实现了 Pixmap 接口所需的方法,以便我们可以查询 Pixmap 的宽度和高度,以及它的格式,并确保像素可以从 RAM 中转储。注意位图成员是包私有的,所以我们可以在 AndroidGraphics 中访问它,我们现在将实现它。
AndroidGraphics:满足我们的绘图需求
我们在第三章中设计的图形界面也是精益吝啬的。它将画像素,线条,矩形和像素映射到帧缓冲区。如前所述,我们将使用位图作为帧缓冲区,并通过画布将所有绘图调用指向它。它还负责从资源文件创建位图实例。因此,我们还需要另一个素材管理者。清单 5-14 显示了我们实现接口 AndroidGraphics 的代码,并附有注释。
清单 5-14 。**【AndroidGraphics.java】;实现图形接口
package com.badlogic.androidgames.framework.impl;
import java.io.IOException;
import java.io.InputStream;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
import android.graphics.BitmapFactory;
import android.graphics.BitmapFactory.Options;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.Rect;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Pixmap;
public class AndroidGraphics implements Graphics {
AssetManager assets;
Bitmap frameBuffer;
Canvas canvas;
Paint paint;
Rect srcRect = new Rect();
Rect dstRect = new Rect();
该类实现图形接口。它包含一个我们用来加载位图实例的 AssetManager 成员、一个表示我们的人工帧缓冲区的 Bitmap 成员、一个我们用来绘制到人工帧缓冲区的 Canvas 成员、一个我们绘制所需的 Paint 成员以及两个我们实现 AndroidGraphics.drawPixmap()方法所需的 Rect 成员。这最后三个成员就在那里,所以我们不必在每次 draw 调用时都创建这些类的新实例。这将给垃圾收集器带来许多问题。
public AndroidGraphics(AssetManager assets, Bitmap frameBuffer) {
this.assets = assets;
this.frameBuffer = frameBuffer;
this.canvas = new Canvas(frameBuffer);
this.paint = new Paint();
}
在构造函数中,我们得到了一个 AssetManager 和位图,它们从外部代表了我们的人工帧缓冲区。我们将它们存储在各自的成员中,并创建 Canvas 实例,该实例将绘制人工 framebuffer 以及 Paint,我们将它用于一些绘制方法。
public Pixmap newPixmap(String fileName, PixmapFormat format) {
Config config = null ;
if (format == PixmapFormat.*RGB565*)
config = Config.*RGB_565*;
else if (format == PixmapFormat.*ARGB4444*)
config = Config.*ARGB_4444*;
else
config = Config.*ARGB_8888*;
Options options = new Options();
options.inPreferredConfig = config;
InputStream in = null ;
Bitmap bitmap = null ;
try {
in = assets.open(fileName);
bitmap = BitmapFactory.*decodeStream*(in);
if (bitmap ==null )
throw new RuntimeException("Couldn't load bitmap from asset '"
+ fileName + "'");
}catch (IOException e) {
throw new RuntimeException("Couldn't load bitmap from asset '"
+ fileName + "'");
}finally {
if (in != null ) {
try {
in.close();
}catch (IOException e) {
}
}
}
if (bitmap.getConfig() == Config.*RGB_565*)
format = PixmapFormat.*RGB565*;
else if (bitmap.getConfig() == Config.*ARGB_4444*)
format = PixmapFormat.*ARGB4444*;
else
format = PixmapFormat.*ARGB8888*;
return new AndroidPixmap(bitmap, format);
}
newPixmap()方法尝试使用指定的 PixmapFormat 从资源文件中加载位图。我们首先将 PixmapFormat 翻译成在第四章中使用的 Android Config 类的常量之一。接下来,我们创建一个新的 Options 实例,并设置我们的首选颜色格式。然后,我们尝试通过 BitmapFactory 从素材中加载位图,如果出错,就会抛出 RuntimeException。否则,我们检查 BitmapFactory 使用什么格式来加载位图,并将其转换为 PixmapFormat 枚举值。请记住,BitmapFactory 可能会决定忽略我们想要的颜色格式,所以我们必须检查以确定它使用什么来解码图像。最后,我们基于加载的位图及其 PixmapFormat 构造一个新的 AndroidBitmap 实例,并将其返回给调用者。
public void clear(int color) {
canvas.drawRGB((color & 0xff0000) >> 16, (color & 0xff00) >> 8,
(color & 0xff));
}
clear()方法提取指定的 32 位 ARGB 颜色参数的红色、绿色和蓝色分量,并调用 Canvas.drawRGB()方法,该方法用该颜色清除我们的人工帧缓冲区。这个方法忽略了指定颜色的任何 alpha 值,所以我们不必提取它。
public void drawPixel(int x, int y, int color) {
paint.setColor(color);
canvas.drawPoint(x, y, paint);
}
drawPixel()方法通过 Canvas.drawPoint()方法绘制我们的人工帧缓冲区的像素。首先,我们设置 Paint 成员变量的颜色,并将其传递给 drawing 方法以及像素的 x 和 y 坐标。
public void drawLine(int x, int y, int x2, int y2, int color) {
paint.setColor(color);
canvas.drawLine(x, y, x2, y2, paint);
}
drawLine()方法绘制人工 framebuffer 的给定线条,调用 Canvas.drawLine()方法时使用 Paint 成员指定颜色。
public void drawRect(int x, int y, int width, int height, int color) {
paint.setColor(color);
paint.setStyle(Style.*FILL*);
canvas.drawRect(x, y, x + width - 1, y + width - 1, paint);
}
drawRect()方法设置 Paint 成员的颜色和样式属性,以便我们可以绘制一个填充的彩色矩形。在实际的 Canvas.drawRect()调用中,我们必须转换矩形左上角和右下角坐标的 x、y、宽度和高度参数。对于左上角,我们简单地使用 x 和 y 参数。对于右下角,我们将 x 和 y 的宽度和高度相加,然后减去 1。例如,如果我们渲染一个矩形,其 x 和 y 为(10,10),宽度和高度分别为 2 和 2,并且不减去 1,则屏幕上的矩形大小将为 3×3 像素。
public void drawPixmap(Pixmap pixmap, int x, int y, int srcX, int srcY,
int srcWidth, int srcHeight) {
srcRect.left = srcX;
srcRect.top = srcY;
srcRect.right = srcX + srcWidth - 1;
srcRect.bottom = srcY + srcHeight - 1;
dstRect.left = x;
dstRect.top = y;
dstRect.right = x + srcWidth - 1;
dstRect.bottom = y + srcHeight - 1;
canvas.drawBitmap(((AndroidPixmap) pixmap).bitmap, srcRect, dstRect, null );
}
drawPixmap()方法允许我们绘制 Pixmap 的一部分,它设置了实际绘图调用中使用的 Rect 成员的源和目的地。与绘制矩形一样,我们必须将 x 和 y 坐标连同宽度和高度一起转换到左上角和右下角。同样,我们必须减去 1,否则我们将超调 1 个像素。接下来,我们通过 Canvas.drawBitmap()方法执行实际的绘制,如果我们绘制的 Pixmap 具有 PixmapFormat,该方法将自动进行混合。ARGB4444 或 PixmapFormat。ARGB8888 颜色深度。请注意,我们必须将 Pixmap 参数转换为 AndroidPixmap,以便获取位图成员来用画布进行绘制。这有点复杂,但是我们可以确定传入的 Pixmap 实例将是一个 AndroidPixmap。
public void drawPixmap(Pixmap pixmap, int x, int y) {
canvas.drawBitmap(((AndroidPixmap)pixmap).bitmap, x, y, null );
}
第二个 drawPixmap()方法在给定坐标处将完整的 Pixmap 绘制到人工帧缓冲区。同样,我们必须做一些转换来获得 AndroidPixmap 的位图成员。
public int getWidth() {
return frameBuffer.getWidth();
}
public int getHeight() {
return frameBuffer.getHeight();
}
}
最后,我们有 getWidth()和 getHeight()方法,它们简单地返回由 AndroidGraphics 类存储的人工帧缓冲区的大小,并在内部呈现给该类。
AndroidFastRenderView 是我们需要实现的最后一个类。
AndroidFastRenderView:循环,拉伸,循环,拉伸
这个类的名字应该给出未来的事情。在第四章中,我们讨论了使用 SurfaceView 在一个单独的线程中执行连续渲染,这个线程也可以容纳我们游戏的主循环。我们开发了一个非常简单的类,名为 FastRenderView,它是从 SurfaceView 类派生而来的,我们确保我们很好地处理了活动生命周期,并且我们设置了一个线程,以便通过画布持续呈现 SurfaceView。这里,我们将重用这个 FastRenderView 类,并扩充它来做更多的事情:
- 它保存了一个对游戏实例的引用,可以从中获取活动屏幕。我们不断地从 FastRenderView 线程中调用 Screen.update()和 Screen.present()方法。
- 它跟踪传递到活动屏幕的帧之间的时间增量。
它接受 AndroidGraphics 实例绘制的人工帧缓冲区,并将其绘制到 SurfaceView,如果需要,将对其进行缩放。
清单 5-15 显示了 AndroidFastRenderView 类的实现,并在适当的地方添加了注释。
***清单 5-15 。【AndroidFastRenderView.java】***线程化的 SurfaceView 执行我们的游戏代码
package com.badlogic.androidgames.framework.impl;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
public class AndroidFastRenderViewextends SurfaceView implements Runnable {
AndroidGame game;
Bitmap framebuffer;
Thread renderThread = null ;
SurfaceHolder holder;
volatile boolean running = false ;
这个应该看着眼熟。我们只需要再添加两个成员——一个 AndroidGame 实例和一个代表我们的人工帧缓冲区的位图实例。其他成员与第三章中的 FastRenderView 相同。
public AndroidFastRenderView(AndroidGame game, Bitmap framebuffer) {
super (game);
this.game = game;
this.framebuffer = framebuffer;
this.holder = getHolder();
}
在构造函数中,我们简单地用 AndroidGame 参数调用基类的构造函数(这是一个活动;这将在下面的部分中讨论)并将参数存储在各自的成员中。和前面几节一样,我们又一次得到了一个 SurfaceHolder。
public void resume() {
running = true ;
renderThread = new Thread(this );
renderThread.start();
}
resume()方法是 FastRenderView.resume()方法的精确副本,因此我们不再讨论它。简而言之,该方法确保我们的线程与活动生命周期很好地交互。
public void run() {
Rect dstRect = new Rect();
long startTime = System.*nanoTime*();
while (running) {
if (!holder.getSurface().isValid())
continue ;
float deltaTime = (System.*nanoTime*()-startTime) / 1000000000.0f;
startTime = System.*nanoTime*();
game.getCurrentScreen().update(deltaTime);
game.getCurrentScreen().present(deltaTime);
Canvas canvas = holder.lockCanvas();
canvas.getClipBounds(dstRect);
canvas.drawBitmap(framebuffer, null , dstRect, null );
holder.unlockCanvasAndPost(canvas);
}
}
run()方法还有一些特性。第一个新增功能是它能够跟踪每帧之间的增量时间。为此,我们使用 System.nanoTime(),它以长整型返回以纳秒为单位的当前时间。
注意:一纳秒是一秒的十亿分之一。
在每次循环迭代中,我们从上一次循环迭代的开始时间和当前时间之间的差值开始。为了更容易处理这个增量,我们把它转换成秒。接下来,我们保存当前时间戳,我们将在下一次循环迭代中使用它来计算下一个增量时间。有了增量时间,我们调用当前屏幕实例的 update()和 present()方法,这将更新游戏逻辑并将内容渲染到人工帧缓冲区。最后,我们得到了表面视图的画布,并绘制了人工帧缓冲区。如果我们传递给 Canvas.drawBitmap()方法的目标矩形小于或大于 framebuffer,则会自动执行缩放。
注意,我们在这里使用了一个快捷方式,通过 Canvas.getClipBounds()方法获得一个延伸到整个 SurfaceView 的目标矩形。它会将 dstRect 的顶部和左侧成员分别设置为 0 和 0,将底部和右侧成员设置为实际屏幕尺寸(在 Nexus One 上,纵向模式下为 480×800)。该方法的其余部分与我们在上一章的 FastRenderView 测试中使用的完全相同。该方法只是确保线程在活动暂停或销毁时停止。
public void pause() {
running = false ;
while (true ) {
try {
renderThread.join();
return ;
}catch (InterruptedException e) {
// retry
}
}
}
}
该类的最后一个方法 pause()也与 FastRenderView.pause()方法相同,它只是终止渲染/主循环线程,并等待它完全死亡后再返回。
我们差不多完成了我们的框架。拼图的最后一块是游戏界面的实现。
安卓游戏:把所有东西绑在一起
我们的游戏开发框架即将完成。我们所需要做的就是通过实现我们在第三章设计的游戏界面来把松散的部分连接起来。为此,我们将使用我们在本章前面几节中创建的类。以下是责任清单:
- 执行窗口管理。在我们的上下文中,这意味着设置一个活动和一个 AndroidFastRenderView,并以干净的方式处理活动生命周期。
- 使用和管理唤醒锁,使屏幕不会变暗。
- 实例化并向感兴趣的各方分发图形、音频、文件和输入的引用。
- 管理屏幕并将其与活动生命周期集成。
- 我们的总体目标是有一个叫 AndroidGame 的类,我们可以从中派生。我们希望稍后实现 Game.getStartScreen()方法,以下面的方式开始我们的游戏。
public class MrNomextends AndroidGame {
public Screen getStartScreen() {
return new MainMenu(this );
}
}
我们希望你能明白为什么在一头扎进实际的游戏编程之前设计一个可行的框架是有益的。我们可以在未来所有不需要太多图形的游戏中重用这个框架。现在,让我们讨论清单 5-16 ,它显示了 AndroidGame 类,被注释分开。
清单 5-16 。**【AndroidGame.java】;将一切联系在一起
package com.badlogic.androidgames.framework.impl;
import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
import android.os.Bundle;
import android.os.PowerManager;
import android.os.PowerManager.WakeLock;
import android.view.Window;
import android.view.WindowManager;
import com.badlogic.androidgames.framework.Audio;
import com.badlogic.androidgames.framework.FileIO;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input;
import com.badlogic.androidgames.framework.Screen;
public abstract class AndroidGameextends Activity implements Game {
AndroidFastRenderView renderView;
Graphics graphics;
Audio audio;
Input input;
FileIO fileIO;
Screen screen;
WakeLock wakeLock;
类定义从让 AndroidGame 扩展 Activity 类,实现游戏接口开始。接下来,我们定义几个应该已经熟悉的成员。第一个成员是 AndroidFastRenderView,我们将绘制到它,它将为我们管理主循环线程。当然,我们将 Graphics、Audio、Input 和 FileIO 成员设置为 AndroidGraphics、AndroidAudio、AndroidInput 和 AndroidFileIO 的实例。下一个成员持有当前活动的屏幕。最后,有一个成员持有一个唤醒锁,我们用它来防止屏幕变暗。
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.*FEATURE_NO_TITLE*);
getWindow().setFlags(WindowManager.LayoutParams.*FLAG_FULLSCREEN*,
WindowManager.LayoutParams.*FLAG_FULLSCREEN*);
boolean isLandscape = getResources().getConfiguration().orientation == Configuration.*ORIENTATION_LANDSCAPE*;
int frameBufferWidth = isLandscape ? 480 : 320;
int frameBufferHeight = isLandscape ? 320 : 480;
Bitmap frameBuffer = Bitmap.*createBitmap*(frameBufferWidth,
frameBufferHeight, Config.*RGB_565*);
float scaleX = (float ) frameBufferWidth
/ getWindowManager().getDefaultDisplay().getWidth();
float scaleY = (float ) frameBufferHeight
/ getWindowManager().getDefaultDisplay().getHeight();
renderView = new AndroidFastRenderView(this , frameBuffer);
graphics = new AndroidGraphics(getAssets(), frameBuffer);
fileIO = new AndroidFileIO(this );
audio = new AndroidAudio(this );
input = new AndroidInput(this , renderView, scaleX, scaleY);
screen = getStartScreen();
setContentView(renderView);
PowerManager powerManager = (PowerManager) getSystemService(Context.*POWER_SERVICE*);
wakeLock = powerManager.newWakeLock(PowerManager.*FULL_WAKE_LOCK*, "GLGame");
}
onCreate()方法是我们熟悉的 Activity 类的启动方法,它通过根据需要调用基类的 onCreate()方法来启动。接下来,我们让活动全屏显示,就像我们在第四章的其他几个测试中所做的那样。在接下来的几行中,我们设置了我们的人工帧缓冲区。根据活动的方向,我们希望使用 320×480 帧缓冲区(纵向模式)或 480×320 帧缓冲区(横向模式)。为了确定活动的屏幕方向,我们从一个名为 Configuration 的类中获取方向成员,这个类是通过调用 getResources()获得的。getConfiguration()。基于该成员的值,我们然后设置帧缓冲区大小并实例化一个位图,我们将在接下来的章节中把它交给 AndroidFastRenderView 和 AndroidGraphics 实例。
注意位图实例具有 RGB565 颜色格式。这样就不浪费内存,我们的画图完成的也快一点。
注意对于我们的第一个游戏,Nom 先生,我们将使用 320×480 像素的目标分辨率。AndroidGame 类硬编码了这些值。如果你想使用不同的目标分辨率,相应地修改 AndroidGame!
我们还计算 scaleX 和 scaleY 值,SingleTouchHandler 和 MultiTouchHandler 类将使用它们来转换固定坐标系中的触摸事件坐标。
接下来,我们用必要的构造函数参数实例化 AndroidFastRenderView、AndroidGraphics、AndroidAudio、AndroidInput 和 AndroidFileIO。最后,我们调用 getStartScreen()方法,我们的游戏将实现该方法,并将 AndroidFastRenderView 设置为活动的内容视图。当然,所有先前实例化的助手类将在后台做更多的工作。例如,AndroidInput 类告诉选定的触摸处理程序与 AndroidFastRenderView 通信。
@Override
public void onResume() {
super.onResume();
wakeLock.acquire();
screen.resume();
renderView.resume();
}
接下来是 Activity 类的 onResume()方法,我们覆盖了它。像往常一样,我们做的第一件事是调用超类方法。接下来,我们获取唤醒锁,并确保当前屏幕被告知游戏以及活动已经恢复。最后,我们告诉 AndroidFastRenderView 恢复渲染线程,这也将开始我们游戏的主循环,在这里我们告诉当前屏幕在每次迭代中更新和呈现它自己。
@Override
public void onPause() {
super.onPause();
wakeLock.release();
renderView.pause();
screen.pause();
if (isFinishing())
screen.dispose();
}
首先,onPause()方法再次调用超类方法。接下来,它释放唤醒锁,并确保渲染线程终止。如果我们在调用当前屏幕的 onPause()方法之前不终止线程,我们可能会遇到并发问题,因为 UI 线程和主循环线程将同时访问屏幕。一旦我们确定主循环线程不再存在,我们告诉当前屏幕它应该暂停自己。如果活动将被销毁,我们还会通知屏幕,以便它可以做任何必要的清理工作。
public Input getInput() {
return input;
}
public FileIO getFileIO() {
return fileIO;
}
public Graphics getGraphics() {
return graphics;
}
public Audio getAudio() {
return audio;
}
getInput()、getFileIO()、getGraphics()和 getAudio()方法无需解释。我们只是将各自的实例返回给调用者。后来,调用方将始终是我们游戏的屏幕实现之一。
public void setScreen(Screen screen) {
if (screen ==null )
throw new IllegalArgumentException("Screen must not be null");
this.screen.pause();
this.screen.dispose();
screen.resume();
screen.update(0);
this.screen = screen;
}
起初,我们从游戏接口继承的 setScreen()方法看起来很简单。我们从一些传统的空检查开始,因为我们不允许空屏幕。接下来,我们告诉当前屏幕暂停并释放自己,以便为新屏幕腾出空间。新屏幕被要求以零的增量时间自我恢复和自我更新一次。最后,我们将屏幕成员设置为新屏幕。
让我们想想谁会在什么时候调用这个方法。当我们设计 Mr. Nom 时,我们确定了各种屏幕实例之间的所有转换。我们通常会在这些屏幕实例之一的 update()方法中调用 AndroidGame.setScreen()方法。
例如,假设我们有一个主菜单屏幕,在这里我们检查是否在 update()方法中按下了 Play 按钮。如果是这种情况,我们将通过从 MainMenu.update()方法中调用 AndroidGame.setScreen()方法,使用下一个屏幕的全新实例来转换到下一个屏幕。在调用 AndroidGame.setScreen()之后,主菜单屏幕将重新获得控制权,并且应该立即返回到调用方,因为它不再是活动屏幕。在这种情况下,调用者是主循环线程中的 AndroidFastRenderView。如果您检查主循环中负责更新和呈现活动屏幕的部分,您将看到 update()方法将在 MainMenu 类上调用,但是 present()方法将在新的当前屏幕上调用。这可能会有问题,因为我们定义屏幕接口的方式保证了在屏幕被要求显示之前,resume()和 update()方法至少会被调用一次。这就是为什么我们在新屏幕上的 AndroidGame.setScreen()方法中调用这两个方法。AndroidGame 类负责一切。
public Screen getCurrentScreen() {
return screen;
}
}
最后一个方法是 getCurrentScreen()方法,它只返回当前活动的屏幕。
最后,记住 AndroidGame 是从 Game 派生出来的,Game 有另外一个方法叫做 getStartScreen()。这是我们必须实现的方法,让我们的游戏进行下去!
现在,我们已经创建了一个易于使用的 Android 游戏开发框架。我们需要做的就是实现我们游戏的屏幕。我们也可以在未来的游戏中重用这个框架,只要它们不需要强大的图形能力。如果有必要,我们必须使用 OpenGL ES。然而,要做到这一点,我们只需要替换我们框架的图形部分。音频、输入和文件 I/O 的所有其他类都可以重用。
摘要
在这一章中,我们从零开始实现了一个成熟的 2D Android 游戏开发框架,它可以在所有未来的游戏中重用(只要它们在图形上是适度的)。为了实现一个良好的、可扩展的设计,我们非常小心。我们可以把代码和渲染部分替换成 OpenGL ES,这样就可以制作出 3D 的 Nom 先生。
有了所有这些样板代码,让我们专注于我们在这里的目的:编写游戏!*
六、Nom 先生入侵 Android
在第三章中,我们为 Nom 先生做了一个完整的设计,包括游戏机制,一个简单的背景故事,手工制作的图形资源,以及基于一些剪纸的所有屏幕的定义。在《??》第五章中,我们开发了一个成熟的游戏开发框架,让我们可以轻松地将设计画面转换成代码。但是说够了;让我们开始写我们的第一个游戏吧!
创建素材
我们在 Nom 先生有两种素材:音频素材和图形素材。我们通过一个叫做 Audacity 的开源应用和一个糟糕的上网本麦克风录制了音频素材。我们创造了一种声音效果,当按下按钮或选择菜单项时播放,一种是当 Nom 先生吃了污渍时播放,另一种是当他吃了自己时播放。我们将它们作为 OGGs 保存到 assets/文件夹中,分别命名为 click.ogg、eat.ogg 和 bitten.ogg。您可以发挥创造力,使用 Audacity 和麦克风自己创建这些文件,或者您可以在http://code.google.com/p/beginnginandroidgames2/从 SVN 存储库中获取这些文件。如果你不熟悉 SVN,请看前面我们描述如何获得源代码的内容。
早些时候,我们提到过,我们希望将那些设计阶段的剪纸重新用作我们真正的游戏图形。为此,我们首先必须使它们符合我们的目标分辨率。
我们选择了 320 × 480(纵向模式)的固定目标分辨率,我们将为其设计所有的图形素材。这可能看起来很小,但它使我们开发游戏和图形变得非常快速和容易,毕竟,这里的重点是你可以看到整个 Android 游戏开发过程。
对于您的制作游戏,考虑所有的分辨率并使用更高分辨率的图形,以便您的游戏在平板电脑大小的屏幕上看起来很好,也许目标是 800 × 1280 作为基线。我们扫描了所有的剪纸,并稍微调整了一下尺寸。我们将大部分素材保存在单独的文件中,并将其中一些合并到一个文件中。所有图像都以 PNG 格式保存。背景是 RGB888 的唯一图像;其他都是 ARGB8888。图 6-1 向你展示了我们最终的结果。
图 6-1。Nom 先生的所有图形素材及其各自的文件名和像素大小
让我们稍微分解一下这些图像:
- 这是我们的背景图像,这将是我们绘制到 framebuffer 的第一个东西。由于显而易见的原因,它与我们的目标分辨率大小相同。
- 这包含了我们在游戏中需要的所有按钮。我们将它们放在一个文件中,因为我们可以通过 Graphics.drawPixmap()方法轻松地绘制它们,该方法允许绘制图像的一部分。当我们开始用 OpenGL ES 绘图时,我们会更频繁地使用这种技术,所以我们现在最好习惯它。将几幅图像合并成一幅图像通常被称为图册,图像本身被称为图像图册(或纹理图册,或 sprite sheet)。每个按钮的大小为 64 × 64 像素,当我们必须判断触摸事件是否按下了屏幕上的按钮时,这将派上用场。
- help3.png、help3.png 和 help3.png:这些是我们将在 Nom 先生的三个帮助屏幕上显示的图像。它们的大小都一样,这使得将它们放在屏幕上更容易。
- logo.png:这是我们将在主菜单屏幕上显示的徽标。
- 这包含了我们将在主菜单上呈现给玩家的三个选项。选择其中一个将触发到相应屏幕的转换。每个选项的高度大约为 42 像素,我们可以用它来轻松检测哪个选项被触摸了。
- ready.png、pause.png 和 gameover.png:我们会在游戏即将开始、暂停和结束时画出这些。
- numbers.png:它保存了我们稍后获得高分所需的所有数字。关于这个图像需要记住的是,每个数字都有相同的宽度和高度,20 × 32 像素,除了末尾的点,它是 10 × 32 像素。我们可以用它来呈现任何抛给我们的数字。
- tail.png:这是 Nom 先生的尾巴,或者说是他尾巴的一部分。尺寸是 32 × 32 像素。
- headup.png,headup.png,headright.png,还有 headup.png:这些图片是给诺姆先生的头像;他能移动的每个方向都有一个。因为他的帽子,我们不得不把这些图像做得比尾巴图像大一点。每张头像尺寸为 42 × 42 像素。
- stain3.png、stain3.png 和染色剂 3.png:这是我们可以渲染的三种染色剂。拥有三种类型会让游戏画面更多样化一点。它们的尺寸是 32 × 32 像素,就像尾图一样。
很好,现在让我们开始实现屏幕!
设置项目
正如在第五章中提到的,我们将把 Nom 先生的代码与我们的框架代码合并。所有与 Nom 先生相关的类都将被放入包 com . badlogic . androidgames . Mr Nom .另外,我们必须修改清单文件,如第四章所述。我们的默认活动将被称为 MrNomGame。只需按照第四章中“Android 游戏项目设置八个简单步骤”一节中概述的八个步骤,正确设置<活动>属性(也就是说,游戏以纵向模式固定,配置更改由应用处理),并给予我们的应用适当的权限(写入外部存储,使用唤醒锁,等等)。
前面章节中的所有素材都位于项目的素材/文件夹中。此外,我们必须将 ic_launcher.png 文件放入 res/drawable、res/drawable-ldpi、res/drawable-mdpi、res/drawable-hdpi 和 res/drawable-xhdpi 文件夹中。我们只是拿了 Nom 先生的 headright.png,将其重命名为 ic_launcher.png,并在每个文件夹中放了一个大小适当的版本。
剩下的就是把我们的游戏代码放到 Eclipse 项目的 com . bad logic . androidgames . Mr nom 包里了!
MrNomGame:主要活动
我们的应用需要一个主入口点,也就是 Android 上的默认活动。我们将把这个默认活动称为 MrNomGame,并让它从 AndroidGame 派生,这个类是我们在第五章中实现的,用来运行我们的游戏。稍后,它将负责创建和运行我们的第一个屏幕。清单 6-1 展示了我们的 MrNomGame 类。
清单 6-1 。**【MrNomGame.java】;我们的主要活动/游戏混合
package com.badlogic.androidgames.mrnom;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.impl.AndroidGame;
public class MrNomGame extends AndroidGame {
public Screen getStartScreen() {
returnnew LoadingScreen(this );
}
}
我们需要做的就是从 AndroidGame 派生并实现 getStartScreen()方法,这将返回 LoadingScreen 类的一个实例(我们将在一分钟内实现)。请记住,这将使我们从游戏所需的所有东西开始,从设置音频、图形、输入和文件 I/O 的不同模块到启动主循环线程。很简单,是吧?
素材:一个方便的素材商店
加载屏幕将加载我们游戏的所有素材。但是我们把它们存放在哪里呢?为了存储它们,我们将做一些在 Java 领域不常见的事情:我们将创建一个类,它有大量的公共静态成员,这些成员保存我们从素材中加载的所有位图和声音。清单 6-2 显示了那个类。
清单 6-2 。**【Assets.java】;保存我们所有的像素图和声音以便于访问
package com.badlogic.androidgames.mrnom;
import com.badlogic.androidgames.framework.Pixmap;
import com.badlogic.androidgames.framework.Sound;
public class Assets {
public static Pixmap*background*;
public static Pixmap*logo*;
public static Pixmap*mainMenu*;
public static Pixmap*buttons*;
public static Pixmap*help1*;
public static Pixmap*help2*;
public static Pixmap*help3*;
public static Pixmap*numbers*;
public static Pixmap*ready*;
public static Pixmap*pause*;
public static Pixmap*gameOver*;
public static Pixmap*headUp*;
public static Pixmap*headLeft*;
public static Pixmap*headDown*;
public static Pixmap*headRight*;
public static Pixmap*tail*;
public static Pixmap*stain1*;
public static Pixmap*stain2*;
public static Pixmap*stain3*;
public static Sound*click*;
public static Sound*eat*;
public static Sound*bitten*;
}
我们从素材中加载的每个图像和声音都有一个静态成员。如果我们想使用这些素材中的一个,我们可以这样做:
game.getGraphics().drawPixmap(Assets.background, 0, 0)
或者类似这样的东西:
Assets.click.play(1);
这下方便了。但是,请注意,没有什么可以阻止我们覆盖这些静态成员,因为它们不是最终的。但是只要我们不覆盖它们,我们就是安全的。这些公共的、非最终的成员实际上使这个“设计模式”成为一个反模式。不过,对于我们的游戏来说,稍微懒一点是可以的。一个更干净的解决方案是将素材隐藏在所谓的单例类的 setters 和 getters 之后。我们会坚持我们穷人的素材经理。
设置:跟踪用户选择和高分
在加载屏幕中我们还需要加载另外两个东西:用户设置和高分。如果你回头看看第三章中的主菜单和高分屏幕,你会看到我们允许用户切换声音,并且我们存储了前五个高分。我们会将这些设置保存到外部存储器,以便下次游戏开始时可以重新加载它们。为此,我们将实现另一个简单的类,名为 Settings,如清单 6-3 所示。列表被拆分,评论相互交错。
清单 6-3 。**【Settings.java】;存储我们的设置并加载/保存它们
package com.badlogic.androidgames.mrnom;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import com.badlogic.androidgames.framework.FileIO;
public class Settings {
public static boolean *soundEnabled* = true ;
public static int[] *highscores* = new int[] { 100, 80, 50, 30, 10 };
是否回放声音效果由一个名为 soundEnabled 的公共静态布尔值决定。高分存储在一个五元素整数数组中,从最高到最低排序。我们为这两种设置定义了合理的默认值。我们可以像访问 Assets 类的成员一样访问这两个成员。
public static void load(FileIO files) {
BufferedReader in = null ;
try {
in = new BufferedReader( new InputStreamReader(
files.readFile(".mrnom")));
*soundEnabled* = Boolean.*parseBoolean*(in.readLine());
for (int i = 0; i < 5; i++) {
*highscores*[i] = Integer.*parseInt*(in.readLine());
}
} catch (IOException e) {
// :( It's ok we have defaults
} catch (NumberFormatException e) {
// :/ It's ok, defaults save our day
} finally {
try {
if (in !=null )
in.close();
}catch (IOException e) {
}
}
}
静态 load()方法尝试从名为。来自外部存储器的 mrnom。为此它需要一个 FileIO 实例,我们将它传递给方法。它假设声音设置和每个高分条目存储在单独的行上,并简单地读入它们。如果出现任何问题(例如,如果外部存储不可用或者还没有设置文件),我们只需返回到默认值并忽略故障。
public static void save(FileIO files) {
BufferedWriter out = null ;
try {
out = new BufferedWriter(new OutputStreamWriter(
files.writeFile(".mrnom")));
out.write(Boolean.*toString*(*soundEnabled*));
for (int i = 0; i < 5; i++) {
out.write(Integer.*toString*(*highscores*[i]));
}
}catch (IOException e) {
}finally {
try {
if (out !=null )
out.close();
}catch (IOException e) {
}
}
}
接下来是一个叫做 save()的方法。它获取当前设置并将它们序列化到。外部存储器上的 mrnom 文件(即/sdcard/。mrnom)。正如 load()方法所期望的那样,声音设置和每个高分条目都作为单独的一行存储在该文件中。如果出现问题,我们只需忽略失败并使用前面定义的默认值。在 AAA 标题中,您可能希望通知用户这个加载错误。
值得注意的是,在 Android API 8 中,添加了更多特定的方法来处理托管的外部存储。添加了 Context.getExternalFilesDir()方法,它在外部存储中提供了一个特定的点,不会污染 SD 卡或内部闪存的根目录,并且在卸载应用时也会被清理。当然,增加对它的支持意味着要么为 API 8 动态加载一个类,要么将 SDK 的最小值设置为 8,这样就失去了向后兼容性。为了简单起见,Nom 先生将使用旧的 API 1 外部存储点,但是如果您需要一个如何动态加载类的例子,只需看看我们在第五章中的 TouchHandler 代码。
public static void addScore(int score) {
for (int i = 0; i < 5; i++) {
if (*highscores*[i] < score) {
for (int j = 4; j > i; j--)
*highscores*[j] = *highscores*[j - 1];
*highscores*[i] = score;
break ;
}
}
}
}
最后一个方法 addScore()是一个方便的方法。我们将使用它向高分添加一个新的分数,根据我们想要插入的值自动重新排序。
LoadingScreen:从磁盘获取素材
有了这些类,我们现在可以轻松地实现加载屏幕。清单 6-4 显示了代码。
清单 6-4 。**【LoadingScreen.java】;加载所有素材和设置
package com.badlogic.androidgames.mrnom;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.Graphics.PixmapFormat;
public class LoadingScreen extends Screen {
public LoadingScreen(Game game) {
super (game);
}
我们让 LoadingScreen 类从我们在第三章中定义的 Screen 类派生出来。这要求我们实现一个接受游戏实例的构造函数,我们把它交给超类构造函数。注意,这个构造函数将在我们前面定义的 MrNomGame.getStartScreen()方法中被调用。
public void update(float deltaTime) {
Graphics g = game.getGraphics();
Assets.*background* = g.newPixmap("background.png", PixmapFormat.*RGB565*);
Assets.*logo* = g.newPixmap("logo.png", PixmapFormat.*ARGB4444*);
Assets.*mainMenu* = g.newPixmap("mainmenu.png", PixmapFormat.*ARGB4444*);
Assets.*buttons* = g.newPixmap("buttons.png", PixmapFormat.*ARGB4444*);
Assets.*help1* = g.newPixmap("help1.png", PixmapFormat.*ARGB4444*);
Assets.*help2* = g.newPixmap("help2.png", PixmapFormat.*ARGB4444*);
Assets.*help3* = g.newPixmap("help3.png", PixmapFormat.*ARGB4444*);
Assets.*numbers* = g.newPixmap("numbers.png", PixmapFormat.*ARGB4444*);
Assets.*ready* = g.newPixmap("ready.png", PixmapFormat.*ARGB4444*);
Assets.*pause* = g.newPixmap("pausemenu.png", PixmapFormat.*ARGB4444*);
Assets.*gameOver* = g.newPixmap("gameover.png", PixmapFormat.*ARGB4444*);
Assets.*headUp* = g.newPixmap("headup.png", PixmapFormat.*ARGB4444*);
Assets.*headLeft* = g.newPixmap("headleft.png", PixmapFormat.*ARGB4444*);
Assets.*headDown* = g.newPixmap("headdown.png", PixmapFormat.*ARGB4444*);
Assets.*headRight* = g.newPixmap("headright.png", PixmapFormat.*ARGB4444*);
Assets.*tail* = g.newPixmap("tail.png", PixmapFormat.*ARGB4444*);
Assets.*stain1* = g.newPixmap("stain1.png", PixmapFormat.*ARGB4444*);
Assets.*stain2* = g.newPixmap("stain2.png", PixmapFormat.*ARGB4444*);
Assets.*stain3* = g.newPixmap("stain3.png", PixmapFormat.*ARGB4444*);
Assets.*click* = game.getAudio().newSound("click.ogg");
Assets.*eat* = game.getAudio().newSound("eat.ogg");
Assets.*bitten* = game.getAudio().newSound("bitten.ogg");
Settings.*load*(game.getFileIO());
game.setScreen(new MainMenuScreen(game));
}
接下来是 update()方法的实现,在这里我们加载素材和设置。对于图像素材,我们只需通过 Graphics.newPixmap()方法创建新的像素图。注意,我们指定了位图应该具有的颜色格式。背景为 RGB565 格式,所有其他图像为 ARGB4444 格式(如果 BitmapFactory 尊重我们的提示)。我们这样做是为了节省内存,并在稍后提高渲染速度。我们的原始图像以 RGB888 和 ARGB8888 格式存储为 png。我们还加载了三个声音效果,并将它们存储在 Assets 类的各个成员中。接下来,我们通过 Settings.load()方法从外部存储器加载设置。最后,我们启动一个屏幕转换到一个名为 MainMenuScreen 的屏幕,它将从那时起接管执行。
public void present(float deltaTime) {
}
public void pause() {
}
public void resume() {
}
public void dispose() {
}
}
其他方法只是存根,不执行任何操作。由于 update()方法将在加载所有素材后立即触发屏幕转换,所以在这个屏幕上没有什么可做的了。
主菜单屏幕
主菜单屏幕是相当愚蠢的。它只是以切换按钮的形式呈现徽标、主菜单选项和声音设置。它所做的只是对主菜单选项或声音设置切换按钮上的触摸做出反应。为了实现这一行为,我们需要知道两件事:我们在屏幕上的什么地方呈现图像,以及触发屏幕转换或切换声音设置的触摸区域是什么。图 6-2 显示了我们在屏幕上渲染不同图像的位置。由此我们可以直接得出触摸面积。
图 6-2。主菜单屏幕。坐标指定了我们渲染不同图像的位置,轮廓显示了触摸区域。
计算徽标和主菜单选项图像的 x 坐标,使它们以 x 轴为中心。
接下来,我们来实现屏幕。清单 6-5 显示了代码。
清单 6-5 。**【MainMenuScreen.java】;主菜单屏幕
package com.badlogic.androidgames.mrnom;
import java.util.List;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Screen;
public class MainMenuScreen extends Screen {
public MainMenuScreen(Game game) {
super (game);
}
我们让这个类再次从 Screen 派生,并为它实现一个合适的构造函数。
public void update(float deltaTime) {
Graphics g = game.getGraphics();
List < TouchEvent > touchEvents = game.getInput().getTouchEvents();
game.getInput().getKeyEvents();
int len = touchEvents.size();
for (int i = 0; i < len; i++) {
TouchEvent event = touchEvents.get(i);
if (event.type == TouchEvent.*TOUCH_UP*) {
if (inBounds(event, 0, g.getHeight() - 64, 64, 64)) {
Settings.*soundEnabled* = !Settings.*soundEnabled*;
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
}
if (inBounds(event, 64, 220, 192, 42) ) {
game.setScreen(new GameScreen(game));
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
return ;
}
if (inBounds(event, 64, 220 + 42, 192, 42) ) {
game.setScreen(new HighscoreScreen(game));
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
return ;
}
if (inBounds(event, 64, 220 + 84, 192, 42) ) {
game.setScreen(new HelpScreen(game));
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
return ;
}
}
}
}
接下来,我们有 update()方法,,我们将在其中进行所有的触摸事件检查。我们首先从游戏提供给我们的输入实例中获取 TouchEvent 和 KeyEvent 实例。注意,我们不使用 KeyEvent 实例,但是为了清除内部缓冲区,我们还是获取了它们(是的,这有点讨厌,但是让我们养成习惯)。然后,我们遍历所有的 TouchEvent 实例,直到找到一个类型为 TouchEvent 的实例。润色。(我们也可以寻找触摸事件。TOUCH_DOWN 事件,但是在大多数 UI 中,up 事件用于指示 UI 组件被按下。)
一旦我们有了一个 fitting 事件,我们就检查它是按下了声音切换按钮还是某个菜单项。为了使代码更加简洁,我们编写了一个名为 inBounds()的方法,它接受一个触摸事件、x 和 y 坐标以及宽度和高度。该方法检查触摸事件是否在由这些参数定义的矩形内,并返回 true 或 false。
如果声音切换按钮被按下,我们只需反转设置。如果任何主菜单项被按下,我们通过实例化它并通过 Game.setScreen()设置它来转换到适当的屏幕。在这种情况下,我们可以立即返回,因为 MainMenuScreen 屏幕已经没有任何事情可做了。如果按下切换按钮或主菜单条目并启用声音,我们还会播放卡嗒声。
请记住,所有触摸事件都将相对于我们的目标分辨率 320 × 480 像素进行报告,这要归功于我们在第五章中讨论的触摸事件处理程序中执行的缩放魔法。
private boolean inBounds(TouchEvent event, int x, int y, int width, int height) {
if (event.x > x && event.x < x + width - 1 &&
event.y > y && event.y < y + height - 1)
return true ;
else
return false ;
}
inBounds()方法的工作方式与前面讨论的一样:放入一个触摸事件和一个矩形,它会告诉您触摸事件的坐标是否在该矩形内。
public void present(float deltaTime) {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*background*, 0, 0);
g.drawPixmap(Assets.*logo*, 32, 20);
g.drawPixmap(Assets.*mainMenu*, 64, 220);
if (Settings.*soundEnabled*)
g.drawPixmap(Assets.*buttons*, 0, 416, 0, 0, 64, 64);
else
g.drawPixmap(Assets.*buttons*, 0, 416, 64, 0, 64, 64);
}
present()方法可能是您最期待的方法,但它并不那么令人兴奋。我们的小游戏框架使得渲染我们的主菜单屏幕变得非常简单。我们所做的就是在(0,0)处渲染背景,这将基本上擦除我们的帧缓冲区,所以不需要调用 Graphics.clear()。接下来,我们在图 6-2 所示的坐标处绘制标志和主菜单条目。我们通过绘制基于当前设置的声音切换按钮来结束该方法。正如你所看到的,我们使用了相同的位图,但是只画了它的适当部分(声音切换按钮;参见图 6-1 。这很容易。
public void pause() {
Settings.*save*(game.getFileIO());
}
我们需要讨论的最后一部分是 pause()方法。由于我们可以更改该屏幕上的一个设置,我们必须确保它保存在外部存储器中。有了我们的设置类,这也很容易!
public void resume() {
}
public void dispose() {
}
}
resume()和 dispose()方法在这个屏幕中没有任何作用。
帮助屏幕类
接下来,让我们实现之前在 update()方法中使用的 HelpScreen、HighscoreScreen 和 GameScreen 类。
我们在第三章中定义了三个帮助界面,每个或多或少解释了游戏的一个方面。我们现在直接将它们转化为屏幕实现,称为 HelpScreen、HelpScreen2 和 HelpScreen3。它们都有一个启动屏幕转换的按钮。帮助屏幕 3 屏幕将转换回主菜单屏幕。图 6-3 显示了带有绘图坐标和触摸区域的三个帮助屏幕。
图 6-3。三个帮助屏幕、绘图坐标和触摸区域
这看起来很容易实现。让我们从 HelpScreen 类开始,如清单 6-6 所示。
清单 6-6 。**【HelpScreen.java】;第一个帮助屏幕
package com.badlogic.androidgames.mrnom;
import java.util.List;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Screen;
public class HelpScreenextends Screen {
public HelpScreen(Game game) {
super (game);
}
@Override
public void update(float deltaTime) {
List < TouchEvent > touchEvents = game.getInput().getTouchEvents();
game.getInput().getKeyEvents();
int len = touchEvents.size();
for (int i = 0; i < len; i++) {
TouchEvent event = touchEvents.get(i);
if (event.type == TouchEvent.*TOUCH_UP*) {
if (event.x > 256 && event.y > 416 ) {
game.setScreen(new HelpScreen2(game));
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
return ;
}
}
}
}
@Override
public void present(float deltaTime) {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*background*, 0, 0);
g.drawPixmap(Assets.*help1*, 64, 100);
g.drawPixmap(Assets.*buttons*, 256, 416, 0, 64, 64, 64);
}
@Override
public void pause() {
}
@Override
public void resume() {
}
@Override
public void dispose() {
}
}
同样,非常简单。我们从 Screen 派生,并实现了一个合适的构造函数。接下来,我们有熟悉的 update()方法,它简单地检查底部的按钮是否被按下。如果是这种情况,我们播放卡嗒声并转换到帮助屏幕 2。
present()方法只是再次呈现背景,然后是帮助图像和按钮。
HelpScreen2 和 HelpScreen3 类看起来是一样的;唯一的区别是他们绘制的帮助图像和他们转换到的屏幕。我们可以同意我们不必看他们的代码。在高分屏幕上!
高分屏幕
高分屏幕简单地画出我们存储在设置类中的前五个高分,加上一个花哨的标题告诉玩家他或她在高分屏幕上,左下角还有一个按钮,当按下时会转换回主菜单。有趣的部分是我们如何渲染高分。让我们先看看我们在哪里渲染图像,如图 6-4 所示。
图 6-4。高分屏幕,无高分
这看起来和我们实现的其他屏幕一样简单。但是怎么才能画出动态的分数呢?
渲染数字:一次远足
我们有一个名为 numbers.png 的素材图像,包含从 0 到 9 的所有数字和一个点。每个数字是 20 × 32 像素,点是 10 × 32 像素。数字从左到右按升序排列。高分屏幕应该显示五行,每行显示五个高分中的一个。这样的一行将从高分的位置开始(例如,“1”或者“5”),后跟一个空格,再按实际分数。我们如何做到这一点?
我们有两件事情要处理:numbers.png 图像和 Graphics.drawPixmap()方法,它允许我们将图像的一部分绘制到屏幕上。比方说我们想要的第一行的默认高分(用字符串" 1。100”)呈现在(20,100),以便数字 1 的左上角与这些坐标重合。我们这样调用 Graphics.drawPixmap():
game.getGraphics().drawPixmap(Assets.*numbers*, 20, 100, 20, 0, 20, 32);
我们知道数字 1 的宽度是 20 个像素。我们字符串的下一个字符必须在(20 + 20,100)处呈现。在字符串“1”的情况下。100”,该字符是点,在 numbers.png 图像中宽度为 10 个像素:
game.getGraphics().drawPixmap(Assets.*numbers*, 40, 100, 200, 0, 10, 32);
字符串中的下一个字符需要在(20 + 20 + 10,100)处呈现。那个字符是一个空格,我们不需要画出来。我们需要做的就是在 x 轴上再前进 20 个像素,因为我们假设这是空格字符的宽度。因此,下一个字符 1 将在(20 + 20 + 10 + 20,100)处呈现。看到这里的模式了吗?
给定字符串中第一个字符左上角的坐标,我们可以遍历字符串中的每个字符,绘制它,并根据我们刚刚绘制的字符,将下一个要绘制的字符的 x 坐标增加 20 或 10 个像素。
我们还需要考虑在给定当前角色的情况下,我们应该绘制 numbers.png 图像的哪一部分。为此,我们需要该部分左上角的 x 和 y 坐标,以及它的宽度和高度。y 坐标永远是 0,看图 6-1 应该很明显。高度也是一个常数—在我们的例子中是 32。宽度为 20 像素(如果字符串的字符是数字)或 10 像素(如果是点)。我们唯一需要计算的是 numbers.png 图像中该部分的 x 坐标。我们可以通过下面这个巧妙的小技巧做到这一点。
字符串中的字符可以解释为 Unicode 字符或 16 位整数。这意味着我们实际上可以用这些字符代码进行计算。幸运的是,字符 0 到 9 都有升序的整数表示。我们可以用它来计算一个数字的 number.png 图像部分的 x 坐标,如下所示:
char character = string.charAt(index);
int x = (character – '0') * 20;
这将为字符 0 提供 0,为字符 3 提供 3 × 20 = 60,依此类推。这就是每个数字部分的 x 坐标。当然,这不适用于点字符,所以我们需要特别对待。让我们用一种方法来总结这一点,该方法可以呈现我们的一条高分线,给定该线的字符串以及应该开始呈现的 x 和 y 坐标:
public void drawText(Graphics g, String line, int x, int y) {
int len = line.length();
for (int i = 0; i < len; i++) {
char character = line.charAt(i);
if (character == ' ') {
x += 20;
continue ;
}
int srcX = 0;
int srcWidth = 0;
if (character == '.') {
srcX = 200;
srcWidth = 10;
}else {
srcX = (character - '0') * 20;
srcWidth = 20;
}
g.drawPixmap(Assets.*numbers*, x, y, srcX, 0, srcWidth, 32);
x += srcWidth;
}
}
我们迭代字符串中的每个字符。如果当前字符是一个空格,我们只需将 x 坐标前移 20 个像素。否则,我们计算 numbers.png 图像中当前字符区域的 x 坐标和宽度。该字符是一个数字或一个点。然后,我们渲染当前字符,并将渲染 x 坐标提升我们刚刚绘制的字符的宽度。如果我们的字符串包含除了空格、数字和点以外的任何内容,这个方法当然会失败。你能想出一种方法让它适用于任何字符串吗?
实现屏幕
有了这些新知识,我们现在可以很容易地实现 HighscoreScreen 类,如清单 6-7 所示。
清单 6-7 。**【HighscoreScreen.java】;向我们展示我们迄今为止取得的最好成绩
package com.badlogic.androidgames.mrnom;
import java.util.List;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.Input.TouchEvent;
public class HighscoreScreenextends Screen {
String lines[] = new String[5];
public HighscoreScreen(Game game) {
super (game);
for (int i = 0; i < 5; i++) {
lines[i] = "" + (i + 1) + ". " + Settings.*highscores*[i];
}
}
因为我们希望与垃圾收集器保持友好关系,所以我们将五个高分行的字符串存储在一个字符串数组成员中。我们基于构造函数中的 Settings.highscores 数组来构造字符串。
@Override
public void update(float deltaTime) {
List < TouchEvent > touchEvents = game.getInput().getTouchEvents();
game.getInput().getKeyEvents();
int len = touchEvents.size();
for (int i = 0; i < len; i++) {
TouchEvent event = touchEvents.get(i);
if (event.type == TouchEvent.*TOUCH_UP*) {
if (event.x < 64 && event.y > 416) {
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
game.setScreen(new MainMenuScreen(game));
return ;
}
}
}
}
接下来,我们定义 update()方法,毫无疑问这很无聊。我们所做的就是检查一个触发事件是否按下了左下角的按钮。如果是这种情况,我们播放卡嗒声并转换回主菜单屏幕。
@Override
public void present(float deltaTime) {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*background*, 0, 0);
g.drawPixmap(Assets.*mainMenu*, 64, 20, 0, 42, 196, 42);
int y = 100;
for (int i = 0; i < 5; i++) {
drawText(g, lines[i], 20, y);
y += 50;
}
g.drawPixmap(Assets.*buttons*, 0, 416, 64, 64, 64, 64);
}
借助我们之前定义的强大的 drawText()方法,present()方法非常简单。像往常一样,我们首先渲染背景图像,然后是 Assets.mainmenu 图像的“HIGHSCORES”部分。我们可以将它存储在一个单独的文件中,但是我们重用它来释放更多的内存。
接下来,我们遍历在构造函数中创建的每一个高分行的五个字符串。我们用 drawText()方法绘制每一行。第一行从(20,100)开始,下一行在(20,150)渲染,依此类推。我们只是将每行文本渲染的 y 坐标增加 50 个像素,这样我们就可以在两行之间有一个很好的垂直间距。我们通过画按钮来结束这个方法。
public void drawText(Graphics g, String line, int x, int y) {
int len = line.length();
for (int i = 0; i < len; i++) {
char character = line.charAt(i);
if (character == ' ') {
x += 20;
continue ;
}
int srcX = 0;
int srcWidth = 0;
if (character == '.') {
srcX = 200;
srcWidth = 10;
}else {
srcX = (character - '0') * 20;
srcWidth = 20;
}
g.drawPixmap(Assets.*numbers*, x, y, srcX, 0, srcWidth, 32);
x += srcWidth;
}
}
@Override
public void pause() {
}
@Override
public void resume() {
}
@Override
public void dispose() {
}
}
剩下的方法应该是不言自明的。让我们来看看 Nom 先生游戏中缺少的最后一块:游戏屏幕。
抽象 Nom 先生的世界:模型、视图、控制器
到目前为止,我们只为我们的素材和设置实现了无聊的 UI 和一些内务代码。我们现在将抽象出 Nom 先生的世界和其中的所有物体。我们也会把 Nom 先生从屏幕分辨率中解放出来,让他活在自己的小世界里,有自己的小坐标系。
如果你是一个长期的程序员,你可能听说过设计模式。给定一个场景,它们或多或少是设计代码的策略。有些是学术上的,有些在现实世界中有用途。对于游戏开发,可以借鉴模型-视图-控制器(MVC) 设计模式的一些思路。它经常被数据库和 web 社区用来将数据模型从表示层和数据操作层中分离出来。我们不会严格遵循这种设计模式,而是采用更简单的形式。
那么这对诺姆先生来说意味着什么呢?首先,我们需要一个独立于任何位图、声音、帧缓冲区或输入事件的世界的抽象表示。相反,我们将以面向对象的方式用几个简单的类来模拟 Nom 先生的世界。我们将为世界上的污点上一堂课,为诺姆先生自己上一堂课。Nom 先生由头部和尾部组成,我们也用单独的类来表示。为了将一切联系在一起,我们将有一个无所不知的类来代表 Nom 先生的整个世界,包括污点和 Nom 先生本人。所有这些都代表了 MVC 的模型部分。
MVC 中的视图将是负责渲染 Nom 先生世界的代码。我们将有一个类或方法来获取这个类,读取它的当前状态,并将其呈现在屏幕上。如何渲染与模型类无关,这是从 MVC 学到的最重要的一课。模型类独立于一切,但是视图类和方法依赖于模型类。
最后,我们有 MVC 中的控制器。它告诉模型类根据用户输入或时间流逝等改变它们的状态。模型类向控制器提供方法(例如,使用类似“将 Mr. Nom 转向左边”的指令),控制器可以使用这些方法来修改模型的状态。我们在模型类中没有任何直接访问触摸屏或加速度计的代码。这样,我们可以保持模型类没有任何外部依赖。
这听起来可能很复杂,你可能想知道为什么我们要这样做。然而,这种方法有很多好处。我们可以实现我们所有的游戏逻辑,而不必了解图形、音频或输入设备。我们可以修改游戏世界的渲染,而不必改变模型类本身。我们甚至可以将 2D 世界渲染器与 3D 世界渲染器进行交换。通过使用控制器,我们可以很容易地增加对新输入设备的支持。它所做的只是将输入事件转换成模型类的方法调用。想通过加速度计转动诺姆先生吗?没问题——读取控制器中的加速度计值,并在 Nom 先生的模型上将它们转换为“左转 Nom 先生”或“右转 Nom 先生”方法调用。想要增加对 Zeemote 的支持吗?没问题,就像加速度计的情况一样!使用控制器最好的一点是,我们不需要接触 Nom 先生的任何一行代码就可以实现所有这些。
让我们从定义 Nom 先生的世界开始。为此,我们将稍微脱离严格的 MVC 模式,使用我们的图形素材来说明基本思想。这也将有助于我们稍后实现视图组件(以像素为单位呈现 Nom 先生的抽象世界)。
图 6-5 显示了游戏屏幕,上面以网格的形式叠加了 Nom 先生的世界。
图 6-5。Nom 先生的世界叠加在我们的游戏屏幕上
请注意,Nom 先生的世界被限制在一个 10 × 13 单元的网格中。我们在一个坐标系统中处理细胞,其原点在左上角(0,0),跨越到右下角(9,12)。Nom 先生的任何部分都必须在这些单元中的一个中,因此,在这个世界中具有整数 x 和 y 坐标。这个世界的污点也是如此。Nom 先生的每一部分恰好适合一个 1 × 1 单位的单元。请注意,单位的类型并不重要——这是我们自己的幻想世界,摆脱了 SI 系统或像素的束缚!
诺姆先生不能离开这个小小的世界。如果他通过一个边缘,他会从另一端出来,他所有的部分都会跟着出来。(顺便说一下,我们在地球上也有同样的问题——朝任何方向走足够长的时间,你都会回到你的起点。)诺姆先生也只能一个细胞一个细胞地前进。他所有的部分都会一直在整数坐标上。比如说,他永远不会占据两个半牢房。
注意如前所述,我们这里使用的不是严格的 MVC 模式。如果你对 MVC 模式的真正定义感兴趣,我们建议你读一读由 Erich Gamm,Richard Helm,Ralph Johnson 和 John M. Vlissides(又名四人组)(Addison-Wesley,1994)撰写的 设计模式:可重用面向对象软件的元素】。在他们的书中,MVC 模式被称为观察者模式。
污渍课
在诺姆先生的世界里,最简单的物体就是污渍。它只是坐在世界的一个细胞里,等着被吃掉。当我们设计 Nom 先生时,我们创造了三种不同的污渍视觉表现。在 Nom 先生的世界里,污渍的类型并不重要,但我们还是会把它包含在我们的污渍类中。清单 6-8 显示了污点等级。
***清单 6-8 。***Stain.java
package com.badlogic.androidgames.mrnom;
public class Stain {
public static final int *TYPE_1* = 0;
public static final int *TYPE_2* = 1;
public static final int *TYPE_3* = 2;
public int x, y;
public int type;
public Stain(int x, int y, int type) {
this .x = x;
this .y = y;
this .type = type;
}
}
Stain 类定义了三个公共静态常量,它们对污点的类型进行编码。每个 Stain 实例都有三个成员,Nom 先生世界中的 x 和 y 坐标,以及一个类型,它是之前定义的常量之一。为了使我们的代码简单,我们不包括 getters 和 setters,这是常见的做法。我们用一个很好的构造函数结束了这个类,它允许我们很容易地实例化一个 Stain 实例。
需要注意的一点是,它缺少与图形、声音或其他类的任何联系。污渍类独立存在,自豪地编码了 Nom 先生世界中污渍的属性。
蛇和蛇的一部分类
Nom 先生就像一条移动的链条,由相互关联的部分组成,当我们选取一部分并将其拖到某个地方时,这些部分就会一起移动。在 Nom 先生的世界里,每个部分占据一个细胞,就像一个污点。在我们的模型中,我们不区分头部和尾部,所以我们可以有一个单独的类来表示 Nom 先生的两种类型的部分。清单 6-9 显示了 SnakePart 类,它用于定义 Nom 先生的两个部分。
***清单 6-9 。***SnakePart.java
package com.badlogic.androidgames.mrnom;
public class SnakePart {
public int x, y;
public SnakePart(int x, int y) {
this .x = x;
this .y = y;
}
}
这与 Stain 类本质上是一样的——我们只是移除了类型成员。我们的 Nom 先生的世界模型的第一个真正有趣的类是 Snake 类。让我们想想它必须能够做什么:
- 它必须存储头部和尾部。
- 它必须知道 Nom 先生目前的走向。
- 当诺姆先生吃了一个污渍时,它一定能长出一个新的尾巴。
- 它必须能够在当前方向上移动一个单元。
第一和第二项很容易。我们只需要 SnakePart 实例的列表——列表中的第一部分是头部,其他部分组成尾部。Nom 先生可以上下左右移动。我们可以用一些常量对其进行编码,并将他当前的方向存储在 Snake 类的一个成员中。
第三项也没那么复杂。我们只是在已有的零件列表中添加了另一个 SnakePart。问题是,那部分应该加在什么位置?这听起来可能令人惊讶,但我们给它的位置与列表中的最后一部分相同。当我们看到如何实现前面列表中的最后一项:移动 Nom 先生时,这样做的原因就变得更清楚了。
图 6-6 显示了 Nom 先生的初始配置。他由三部分组成:头部(5,6)和两个尾部(5,7)和(5,8)。
图 6-6。Nom 先生的初始配置
列表中的部分是有序的,从头部开始,到最后一个尾部结束。当诺姆先生前进一个细胞时,他脑袋后面的所有部分都必须跟着前进。然而,Nom 先生的各个部分可能不会像图 6-6 中那样呈直线排列,因此简单地将所有部分向 Nom 先生前进的方向移动是不够的。我们必须做一些更复杂的事情。
我们需要从列表中的最后一部分开始,这听起来可能有些违反直觉。我们将它移动到它之前的部分的位置,并对列表中的所有其他部分重复此操作,除了头部,因为它之前没有任何部分。在头部的情况下,我们检查 Nom 先生当前的方向,并相应地修改头部的位置。图 6-7 用 Nom 先生的更复杂的配置说明了这一点。
图 6-7。诺姆先生带着他的尾巴前进
这种运动策略和我们的饮食策略配合得很好。当我们向 Nom 先生添加一个新部件时,在 Nom 先生下一次移动时,它将停留在与之前部件相同的位置。另外,请注意,如果 Nom 先生通过了其中一条边,这将允许我们轻松地将他包装到世界的另一边。我们只要相应地设置头部的位置,剩下的就是自动完成的了。
有了这些信息,我们现在可以实现代表 Nom 先生的 Snake 类。清单 6-10 显示了代码。
清单 6-10 。**【Snake.java】;代号的诺姆先生
package com.badlogic.androidgames.mrnom;
import java.util.ArrayList;
import java.util.List;
public class Snake {
public static final int *UP* = 0;
public static final int *LEFT* = 1;
public static final int *DOWN* = 2;
public static final int *RIGHT* = 3;
public List < SnakePart > parts = new ArrayList < SnakePart > ();
public int direction;
首先,我们定义几个常量来编码 Nom 先生的方向。请记住,Nom 先生只能向左转和向右转,因此我们定义常量值的方式至关重要。稍后,它将允许我们轻松地将方向旋转正负 90 度,只需将常量的当前方向递增和递减 1 即可。
接下来,我们定义一个名为 parts 的列表来保存 Nom 先生的所有部分。列表中第一项是头部,其他项是尾部。蛇类的第二个成员掌握着 Nom 先生目前前进的方向。
public Snake() {
direction = *UP*;
parts.add(new SnakePart(5, 6));
parts.add(new SnakePart(5, 7));
parts.add(new SnakePart(5, 8));
}
在构造器中,我们设置 Nom 先生由他的头部和两个额外的尾部组成,差不多位于世界的中间,如前面的图 6-6 所示。我们还设置了蛇的方向。向上,这样 Nom 先生在下一次被要求提升时将向上提升一个单元格。
public void turnLeft() {
direction += 1;
if (direction > *RIGHT*)
direction = *UP*;
}
public void turnRight() {
direction - = 1;
if (direction < *UP*)
direction = *RIGHT*;
}
方法 turnLeft()和 turnRight()只是修改 Snake 类的方向成员。对于左转,我们增加 1,对于右转,我们减少 1。我们还必须确保,如果方向值超出了我们之前定义的常量范围,我们就把 Nom 先生包围起来。
public void eat() {
SnakePart end = parts.get(parts.size()-1);
parts.add(new SnakePart(end.x, end.y));
}
接下来是 eat()方法。它所做的只是在列表末尾添加一个新的 SnakePart。这个新零件将与当前的结束零件具有相同的位置。如前所述,下一次 Nom 先生前进时,这两个重叠的部分将会分开。
public void advance() {
SnakePart head = parts.get(0);
int len = parts.size() - 1;
for (int i = len; i > 0; i--) {
SnakePart before = parts.get(i-1);
SnakePart part = parts.get(i);
part.x = before.x;
part.y = before.y;
}
if (direction ==*UP*)
head.y - = 1;
if (direction ==*LEFT*)
head.x - = 1;
if (direction ==*DOWN*)
head.y += 1;
if (direction ==*RIGHT*)
head.x += 1;
if (head.x < 0)
head.x = 9;
if (head.x > 9)
head.x = 0;
if (head.y < 0)
head.y = 12;
if (head.y > 12)
head.y = 0;
}
下一个方法 advance()实现了图 6-7 中的逻辑。首先,我们从最后一个部分开始,将每个部分移动到它前面的部分的位置。我们把头部排除在这个机制之外。然后,我们根据 Nom 先生当前的方向移动头部。最后,我们执行一些检查,以确保 Nom 先生不会走出他的世界。如果是这样,我们就把他包起来,让他从世界的另一端出来。
public boolean checkBitten() {
int len = parts.size();
SnakePart head = parts.get(0);
for (int i = 1; i < len; i++) {
SnakePart part = parts.get(i);
if (part.x == head.x && part.y == head.y)
return true ;
}
return false ;
}
}
最后一个方法 checkBitten()是一个小助手方法,它检查 Nom 先生是否咬到了自己的尾巴。它所做的只是检查没有一个尾部和头部在同一个位置。如果是这样的话,Nom 先生就死了,游戏也就结束了。
世界一流
我们的最后一个模型类叫做 World。世界级有几项任务要完成:
- 跟踪 Nom 先生(以 Snake 实例的形式),以及世界上出现的 Stain 实例。我们的世界将永远只有一个污点。
- 提供以基于时间的方式更新 Nom 先生的方法(例如,他应该每 0.5 秒前进一个单元格)。这种方法还可以检查 Nom 先生是否吃了污渍或咬了自己。
- 记录分数;这基本上就是目前吃的污渍数乘以 10。
- 诺姆先生每吃十块污渍就增加一次速度。这将使游戏更具挑战性。
- 记录诺姆先生是否还活着。稍后我们会用这个来决定游戏是否结束。
- 在 Nom 先生吃掉当前的污渍后,创建一个新的污渍(一个微妙但重要且复杂得惊人的任务)。
这个任务列表上只有两项我们还没有讨论:以基于时间的方式更新世界和放置新的污点。
诺姆先生的时间运动
在第三章中,我们谈到了基于时间的运动。这基本上意味着我们定义所有游戏对象的速度,测量自上次更新以来经过的时间(也称为增量时间),并通过将对象的速度乘以增量时间来推进对象。在第三章的中给出的例子中,我们使用浮点值来实现这一点。然而,Nom 先生的部件具有整数位置,所以我们需要弄清楚如何在这个场景中推进对象。
我们先定义一下诺姆先生的速度。诺姆先生的世界是有时间的,我们是以秒来衡量的。最初,Nom 先生应该每 0.5 秒前进一个单元格。我们需要做的就是记录从我们上次提升 Nom 先生以来已经过去了多长时间。如果累积的时间超过了我们的 0.5 秒阈值,我们调用 Snake.advance()方法并重置我们的时间累加器。我们从哪里得到这些德尔塔时间?记住 Screen.update()方法。它获得帧增量时间。我们只需将它传递给我们的 World 类的 update()方法,它将进行累加。为了让游戏更具挑战性,我们会在诺姆先生每多吃十个污渍的时候,将阈值减少 0.05 秒。当然,我们必须确保我们不会达到 0 的阈值,否则诺姆先生会以无限的速度旅行——这是爱因斯坦不会喜欢的。
放置污渍
我们要解决的第二个问题是,当 Nom 先生吃掉当前的污渍时,如何放置新的污渍。它应该出现在世界的任意一个单元格中。所以我们可以用一个随机的位置来实例化一个新的污点,对吗?可悲的是,这并不容易。
想象一下诺姆先生占据了相当数量的细胞。有一个合理的可能性,污渍将被放置在一个已经被诺姆先生占据的细胞中,并且它将随着诺姆先生变得越来越大而增加。因此,我们必须找到一个目前没有被 Nom 先生占用的牢房。又轻松了,对吧?只需迭代所有单元格,并使用第一个未被 Nom 先生占用的单元格。
同样,这有点不太理想。如果我们从同一个位置开始寻找,污点就不会随机出现。相反,我们将从世界上的一个随机位置开始,扫描所有单元格,直到到达世界的尽头,然后扫描开始位置以上的所有单元格,如果我们还没有找到一个空闲单元格的话。
我们如何检查一个单元是否空闲?简单的解决方法是检查所有的单元格,获取每个单元格的 x 和 y 坐标,并根据这些坐标检查 Nom 先生的所有部分。我们有 10 × 13 = 130 个单元格,诺姆先生可以占 55 个单元格。那就是 130×55 = 7150 张支票!诚然,大多数设备可以处理,但我们可以做得更好。
我们将创建一个布尔二维数组,其中每个数组元素代表世界上的一个单元格。当我们必须放置一个新的污点时,我们首先遍历 Mr. Nom 的所有部分,并将数组中某个部分所占用的那些元素设置为 true。然后我们简单地选择一个随机的位置,从这里开始扫描,直到我们找到一个空闲的细胞,我们可以在其中放置新的染色剂。Nom 先生由 55 部分组成,需要 130 + 55 = 185 张支票。那好多了!
决定游戏何时结束
我们还要考虑最后一件事:如果所有的细胞都被 Nom 先生占据了呢?在这种情况下,游戏就结束了,因为诺姆先生将正式成为全世界。假设我们在诺姆先生每吃一个污渍时就给分数加 10,那么最高可得分数是((10×13)-3)×10 = 1270 分(记住,诺姆先生已经开始吃三个部分了)。
实现世界级
唷,我们有很多东西要实现,所以让我们开始吧。清单 6-11 显示了世界类的代码。
***清单 6-11 。***World.java
package com.badlogic.androidgames.mrnom;
import java.util.Random;
public class World {
static final int *WORLD_WIDTH* = 10;
static final int *WORLD_HEIGHT* = 13;
static final int *SCORE_INCREMENT* = 10;
static final float *TICK_INITIAL* = 0.5f;
static final float *TICK_DECREMENT* = 0.05f;
public Snake snake;
public Stain stain;
public boolean gameOver = false ;;
public int score = 0;
boolean fields[][] = new boolean [*WORLD_WIDTH*][*WORLD_HEIGHT*];
Random random = new Random();
float tickTime = 0;
float *tick* = *TICK_INITIAL*;
像往常一样,我们首先定义几个常数——在这种情况下,世界的宽度和高度以单元格为单位,我们用来在 Nom 先生每次吃一个染色剂时增加分数的值,用来推进 Nom 先生的初始时间间隔(称为 tick ),以及 Nom 先生每次吃十个染色剂时减少 tick 的值,以便稍微加快速度。
接下来,我们有一些公共成员,它们保存一个 Snake 实例、一个 Stain 实例、一个存储游戏是否结束的布尔值和当前分数。
我们定义了另外四个包私有成员:我们将用来放置新染色的 2D 数组;Random 类的一个实例,通过它我们将产生随机数来放置污点并生成它的类型;时间累加器变量 tickTime,我们将添加帧增量时间;以及一个分笔成交点的当前持续时间,它定义了我们提升 Nom 先生的频率。
public World() {
snake = new Snake();
placeStain();
}
在构造函数中,我们创建了一个 Snake 类的实例,它将具有如图 6-6 所示的初始配置。我们还通过 placeStain()方法放置第一个随机染色。
private void placeStain() {
for (int x = 0; x < *WORLD_WIDTH*; x++) {
for (int y = 0; y < *WORLD_HEIGHT*; y++) {
fields[x][y] = false ;
}
}
int len = snake.parts.size();
for (int i = 0; i < len; i++) {
SnakePart part = snake.parts.get(i);
fields[part.x][part.y] = true ;
}
int stainX = random.nextInt(*WORLD_WIDTH*);
int stainY = random.nextInt(*WORLD_HEIGHT*);
while (true ) {
if (fields[stainX][stainY] ==false )
break ;
stainX += 1;
if (stainX >=*WORLD_WIDTH*) {
stainX = 0;
stainY += 1;
if (stainY >=*WORLD_HEIGHT*) {
stainY = 0;
}
}
}
stain = new Stain(stainX, stainY, random.nextInt(3));
}
placeStain()方法实现了前面讨论的放置策略。我们从清空单元阵列开始。接下来,我们将蛇的各个部分所占据的所有单元格设置为 true。最后,我们从随机位置开始扫描数组,寻找空闲单元。一旦我们找到了一个自由细胞,我们就用随机类型创建一个染色。请注意,如果所有单元格都被 Nom 先生占用,那么循环将永远不会终止。我们将确保在下一个方法中不会发生这种情况。
public void update(float deltaTime) {
if (gameOver)
return ;
tickTime += deltaTime;
while (tickTime > *tick*) {
tickTime - = *tick*;
snake.advance();
if (snake.checkBitten()) {
gameOver = true ;
return ;
}
SnakePart head = snake.parts.get(0);
if (head.x == stain.x && head.y == stain.y) {
score +=*SCORE_INCREMENT*;
snake.eat();
if (snake.parts.size() ==*WORLD_WIDTH***WORLD_HEIGHT*) {
gameOver = true ;
return ;
}else {
placeStain();
}
if (score % 100 == 0 &&*tick*-*TICK_DECREMENT* > 0) {
*tick*- = *TICK_DECREMENT*;
}
}
}
}
}
update()方法负责根据我们传递给它的时间增量更新世界和其中的所有对象。这个方法会调用游戏画面中的每一帧,让世界不断更新。我们从检查游戏是否结束开始。如果是这样的话,那么我们就不需要更新什么了。接下来,我们将增量时间添加到累加器中。while 循环将使用尽可能多的累计滴答(例如,当 tickTime 为 1.2,而一个滴答需要 0.5 秒时,我们可以更新世界两次,在累加器中留下 0.2 秒)。这被称为固定时间步长模拟。
在每次迭代中,我们首先从累加器中减去节拍间隔。接下来,我们告诉诺姆先生前进。我们检查他是否咬了自己,如果是,就设置游戏结束标志。最后,我们检查诺姆先生的头是否和污点在同一个牢房。如果是这样的话,我们增加分数,并告诉 Nom 先生增长。接下来,我们检查 Nom 先生的组成部分是否和世界上的细胞一样多。如果是这样,游戏就结束了,我们从函数中返回。否则,我们用 placeStain()方法放置一个新的染色。我们做的最后一件事是检查 Nom 先生是否又吃了十个污渍。如果是这种情况,我们的阈值大于零,我们将它减少 0.05 秒。分笔成交点会更短,从而让诺姆动作更快。
这就完成了我们的模型类集。我们最不需要实现的就是游戏画面!
GameScreen 类
只需要再实现一个屏幕。让我们看看这个屏幕做了什么:
-
正如 Nom 先生在第三章中的设计所定义的,游戏屏幕可以处于四种状态之一:等待用户确认他或她准备好了,运行游戏,在暂停状态下等待,或者在游戏结束状态下等待用户点击按钮。
-
在就绪状态下,我们简单地要求用户触摸屏幕来开始游戏。
-
在运行状态下,我们更新世界,渲染世界,还告诉 Nom 先生当玩家按下屏幕底部的一个按钮时向左转和向右转。
-
在暂停状态下,我们只显示两个选项:一个是恢复游戏,一个是退出游戏。
-
在游戏结束的状态下,我们告诉用户游戏结束了,并提供一个触摸按钮,这样他或她就可以回到主菜单。
-
对于每个状态,我们有不同的 update()和 present()方法要实现,因为每个状态做不同的事情并显示不同的 UI。
-
一旦游戏结束,我们必须确保存储分数,如果分数很高的话。
这是相当多的责任,这意味着比平常更多的代码。因此,我们将分解这个类的源代码清单。在深入研究代码之前,让我们展示一下如何在每个状态下安排不同的 UI 元素。图 6-8 显示了四种不同的状态。
图 6-8。游戏屏幕有四种状态:就绪、运行、暂停和游戏结束
请注意,我们还在屏幕底部呈现了分数,以及一条将 Nom 先生的世界与底部按钮分开的线。分数是用我们在 HighscoreScreen 中使用的相同例程呈现的。此外,我们根据乐谱字符串宽度将其水平居中。
最后缺失的一点信息是如何根据模型来呈现 Nom 先生的世界。这其实很简单。再看一下图 6-1 和图 6-5 。每个单元格的大小正好是 32 × 32 像素。污点图像也是 32 × 32 像素大小,Nom 先生的尾部也是。Nom 先生各个方向的头像都是 42 × 42 像素,所以不能完全装进一个单元格。不过,这不是问题。要渲染 Nom 先生的世界,我们需要做的就是将每个污点和蛇的部分乘以 32 的世界坐标,以像素为单位得出屏幕上对象的中心——例如,世界坐标为(3,2)的污点在屏幕上的中心为 96 × 64。基于这些中心,剩下要做的就是获取适当的资源,并以这些坐标为中心进行渲染。让我们开始编码吧。清单 6-12 显示了 GameScreen 类。
***清单 6-12 。***GameScreen.java
package com.badlogic.androidgames.mrnom;
import java.util.List;
import android.graphics.Color;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Pixmap;
import com.badlogic.androidgames.framework.Screen;
public class GameScreenextends Screen {
enum GameState {
*Ready*,
*Running*,
*Paused*,
*GameOver*
}
GameState state = GameState.*Ready*;
World world;
int oldScore = 0;
String score = "0";
我们首先定义一个名为 GameState 的枚举,它对我们的四种状态(就绪、运行、暂停和游戏结束)进行编码。接下来,我们定义一个成员保存屏幕的当前状态,另一个成员保存世界实例,还有两个成员以整数和字符串的形式保存当前显示的分数。我们使用最后两个成员的原因是,我们不想在每次抽签时不断地从 World.score 成员中创建新的字符串。相反,我们将缓存字符串,只在分数改变时创建一个新的。那样的话,我们就能和垃圾收集者友好相处了。
public GameScreen(Game game) {
super (game);
world = new World();
}
构造函数调用超类构造函数并创建一个新的 World 实例。在构造函数返回给调用者后,游戏屏幕将处于就绪状态。
@Override
public void update(float deltaTime) {
List < TouchEvent > touchEvents = game.getInput().getTouchEvents();
game.getInput().getKeyEvents();
if (state == GameState.*Ready*)
updateReady(touchEvents);
if (state == GameState.*Running*)
updateRunning(touchEvents, deltaTime);
if (state == GameState.*Paused*)
updatePaused(touchEvents);
if (state == GameState.*GameOver*)
updateGameOver(touchEvents);
}
接下来是屏幕的 update()方法。它所做的只是从输入模块中获取 TouchEvents 和 KeyEvents,然后将更新委托给我们根据当前状态为每个状态实现的四个更新方法之一。
private void updateReady(List < TouchEvent > touchEvents) {
if (touchEvents.size() > 0)
state = GameState.*Running*;
}
下一个方法称为 updateReady()。当屏幕处于就绪状态时,它将被调用。它所做的只是检查屏幕是否被触摸过。如果是这种情况,它会将状态更改为正在运行。
private void updateRunning(List < TouchEvent > touchEvents, float deltaTime) {
int len = touchEvents.size();
for (int i = 0; i < len; i++) {
TouchEvent event = touchEvents.get(i);
if (event.type == TouchEvent.*TOUCH_UP*) {
if (event.x < 64 && event.y < 64) {
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
state = GameState.*Paused*;
return ;
}
}
if (event.type == TouchEvent.*TOUCH_DOWN*) {
if (event.x < 64 && event.y > 416) {
world.snake.turnLeft();
}
if (event.x > 256 && event.y > 416) {
world.snake.turnRight();
}
}
}
world.update(deltaTime);
if (world.gameOver) {
if (Settings.*soundEnabled*)
Assets.*bitten*.play(1);
state = GameState.*GameOver*;
}
if (oldScore !=world.score) {
oldScore = world.score;
score = "" + oldScore;
if (Settings.*soundEnabled*)
Assets.*eat*.play(1);
}
}
updateRunning()方法首先检查屏幕左上角的暂停按钮是否被按下。如果是这种情况,它会将状态设置为暂停。然后检查屏幕底部的控制器按钮是否被按下。注意,这里我们不检查触发事件,而是检查触下事件。如果其中一个按钮被按下,我们告诉世界的蛇实例向左转或向右转。没错,updateRunning()方法包含了我们 MVC 模式的控制器代码!检查完所有触摸事件后,我们告诉世界用给定的增量时间更新自己。如果世界发出游戏结束的信号,我们相应地改变状态,也播放 bitten.ogg 声音。接下来,我们检查我们缓存的旧分数是否与世界存储的分数不同。如果是,那么我们就知道两件事:诺姆先生吃了一颗污渍,乐谱串肯定是改了。在这种情况下,我们播放 eat.ogg 声音。这就是运行状态更新的全部内容。
private void updatePaused(List < TouchEvent > touchEvents) {
int len = touchEvents.size();
for (int i = 0; i < len; i++) {
TouchEvent event = touchEvents.get(i);
if (event.type == TouchEvent.*TOUCH_UP*) {
if (event.x > 80 && event.x <= 240) {
if (event.y > 100 && event.y <= 148) {
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
state = GameState.*Running*;
return ;
}
if (event.y > 148 && event.y < 196) {
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
game.setScreen(new MainMenuScreen(game));
return ;
}
}
}
}
}
updatePaused()方法只是检查菜单选项之一是否被触摸,并相应地改变状态。
private void updateGameOver(List < TouchEvent > touchEvents) {
int len = touchEvents.size();
for (int i = 0; i < len; i++) {
TouchEvent event = touchEvents.get(i);
if (event.type == TouchEvent.*TOUCH_UP*) {
if (event.x >= 128 && event.x <= 192 &&
event.y >= 200 && event.y <= 264) {
if (Settings.*soundEnabled*)
Assets.*click*.play(1);
game.setScreen(new MainMenuScreen(game));
return ;
}
}
}
}
updateGameOver()方法也检查屏幕中间的按钮是否被按下。如果已经按下,那么我们启动屏幕转换回到主菜单屏幕。
@Override
public void present(float deltaTime) {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*background*, 0, 0);
drawWorld(world);
if (state == GameState.*Ready*)
drawReadyUI();
if (state == GameState.*Running*)
drawRunningUI();
if (state == GameState.*Paused*)
drawPausedUI();
if (state == GameState.*GameOver*)
drawGameOverUI();
drawText(g, score, g.getWidth() / 2 - score.length()*20 / 2, g.getHeight() - 42);
}
接下来是渲染方法。present()方法首先绘制背景图像,因为这在所有状态中都需要。接下来,它为我们所处的状态调用相应的绘制方法。最后,它呈现了 Nom 先生的世界,并在屏幕的底部中央画出了分数。
private void drawWorld(World world) {
Graphics g = game.getGraphics();
Snake snake = world.snake;
SnakePart head = snake.parts.get(0);
Stain stain = world.stain;
Pixmap stainPixmap = null ;
if (stain.type == Stain.*TYPE_1*)
stainPixmap = Assets.*stain1*;
if (stain.type == Stain.*TYPE_2*)
stainPixmap = Assets.*stain2*;
if (stain.type == Stain.*TYPE_3*)
stainPixmap = Assets.*stain3*;
int x = stain.x * 32;
int y = stain.y * 32;
g.drawPixmap(stainPixmap, x, y);
int len = snake.parts.size();
for (int i = 1; i < len; i++) {
SnakePart part = snake.parts.get(i);
x = part.x * 32;
y = part.y * 32;
g.drawPixmap(Assets.*tail*, x, y);
}
Pixmap headPixmap = null ;
if (snake.direction == Snake.*UP*)
headPixmap = Assets.*headUp*;
if (snake.direction == Snake.*LEFT*)
headPixmap = Assets.*headLeft*;
if (snake.direction == Snake.*DOWN*)
headPixmap = Assets.*headDown*;
if (snake.direction == Snake.*RIGHT*)
headPixmap = Assets.*headRight*;
x = head.x * 32 + 16;
y = head.y * 32 + 16;
g.drawPixmap(headPixmap, x - headPixmap.getWidth() / 2, y - headPixmap.getHeight() / 2);
}
正如我们刚刚讨论的,drawWorld()方法绘制世界。它首先选择用于渲染污点的像素图,然后绘制它并将其水平居中在屏幕位置。接下来,我们渲染 Nom 先生的所有尾巴部分,这相当简单。最后,我们根据 Nom 先生的指示,选择使用头部的哪一个像素图,并在屏幕坐标中的头部位置绘制该像素图。与其他对象一样,我们也将图像围绕该位置居中。这就是 MVC 中视图的代码。
private void drawReadyUI() {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*ready*, 47, 100);
g.drawLine(0, 416, 480, 416, Color.*BLACK*);
}
private void drawRunningUI() {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*buttons*, 0, 0, 64, 128, 64, 64);
g.drawLine(0, 416, 480, 416, Color.*BLACK*);
g.drawPixmap(Assets.*buttons*, 0, 416, 64, 64, 64, 64);
g.drawPixmap(Assets.*buttons*, 256, 416, 0, 64, 64, 64);
}
private void drawPausedUI() {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*pause*, 80, 100);
g.drawLine(0, 416, 480, 416, Color.*BLACK*);
}
private void drawGameOverUI() {
Graphics g = game.getGraphics();
g.drawPixmap(Assets.*gameOver*, 62, 100);
g.drawPixmap(Assets.*buttons*, 128, 200, 0, 128, 64, 64);
g.drawLine(0, 416, 480, 416, Color.*BLACK*);
}
public void drawText(Graphics g, String line, int x, int y) {
int len = line.length();
for (int i = 0; i < len; i++) {
char character = line.charAt(i);
if (character == ' ') {
x += 20;
continue ;
}
int srcX = 0;
int srcWidth = 0;
if (character == '.') {
srcX = 200;
srcWidth = 10;
}else {
srcX = (character - '0') * 20;
srcWidth = 20;
}
g.drawPixmap(Assets.*numbers*, x, y, srcX, 0, srcWidth, 32);
x += srcWidth;
}
}
drawReadUI()、drawRunningUI()、drawPausedUI()和 drawGameOverUI()方法并不新鲜。基于图 6-8 所示的坐标,它们一如既往地执行相同的旧 UI 渲染。drawText()方法与 HighscoreScreen 中的方法相同,所以我们也不讨论那个方法。
@Override
public void pause() {
if (state == GameState.*Running*)
state = GameState.*Paused*;
if (world.gameOver) {
Settings.*addScore*(world.score);
Settings.*save*(game.getFileIO());
}
}
@Override
public void resume() {
}
@Override
public void dispose() {
}
}
最后,还有一个最后重要的方法,pause(),当活动暂停或者游戏屏幕被另一个屏幕替换时,就会调用这个方法。那是保存我们设置的完美地方。首先,我们将游戏的状态设置为暂停。如果 paused()方法由于活动被暂停而被调用,这将保证当用户返回游戏时会被要求继续游戏。这是很好的行为,因为从离开游戏的地方立即重新开始会有压力。接下来,我们检查游戏屏幕是否处于游戏结束状态。如果是这种情况,我们将玩家获得的分数加到高分中(或者不加,取决于它的值),并将所有设置保存到外部存储器中。
仅此而已。我们已经为 Android 从头开始编写了一个完整的游戏!你可以为自己感到骄傲,因为你已经征服了所有必要的话题,创造了几乎任何你喜欢的游戏。从这里开始,大部分只是化妆品。
摘要
在这一章中,我们在我们的框架上实现了一个完整的游戏,带有所有的铃铛和口哨(没有音乐)。你知道了为什么将模型从视图和控制器中分离出来是有意义的,你也知道了你不需要用像素来定义你的游戏世界。我们可以用这段代码替换 OpenGL ES 的渲染部分,让 Nom 先生变成 3D。我们还可以通过向 Nom 先生添加动画、添加一些颜色、添加新的游戏机制等等来增加当前渲染器的趣味。然而,我们只是触及了可能性的表面。
在继续阅读这本书之前,我们建议拿着游戏代码到处玩玩。添加一些新的游戏模式、能量和敌人——任何你能想到的。
一旦你回来,在下一章,你将加强你的图形编程知识,使你的游戏看起来有点花哨,你也将迈出第一步进入第三维!****