五、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 接口是精简的,也是低劣的。它包含四个方法:一个获取素材的输入流,另一个获取外部存储中文件的输入流,第三个返回外部存储设备上文件的输出流,最后一个获取游戏的共享首选项。在第 4 章中,您学习了如何使用 Android APIs 打开外部存储上的素材和文件。清单 5-1 基于来自第 4 章的知识,展示了 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 实例工作。
请注意,我们不检查外部存储是否可用。如果它不可用,或者如果我们忘记向清单文件添加适当的权限,我们将得到一个异常,因此检查错误是隐式的。现在,我们可以进入框架的下一部分,即音频。
机器人音频、机器人声音和机器人音乐:碰撞、撞击、撞击!
在第 3 章中,我们为我们所有的音频需求设计了三个界面:音频、声音和音乐。Audio 负责从资源文件创建声音和音乐实例。声音可以让我们播放存储在内存中的音效,音乐可以将更大的音乐文件从磁盘传输到声卡。在第 4 章中,你学习了实现这个需要哪些 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 标志,这样其他方法就不会突然抛出异常。接下来,我们将继续学习与输入相关的类。
机器人输入和加速度处理器
使用一些方便的方法,我们在第 3 章中设计的输入界面允许我们在轮询和事件模式下访问加速度计、触摸屏和键盘。将该接口实现的所有代码放在一个文件中的想法有点讨厌,所以我们将所有输入事件处理外包给处理程序类。输入实现将使用这些处理程序来假装它实际上正在执行所有的工作。
加速器手柄:哪边朝上?
让我们从所有处理器中最简单的开始,加速度计处理器。清单 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;
}
}
不出所料,该类实现了我们在第 4 章中使用的 SensorEventListener 接口。该类通过保存三个加速度计轴上的加速度来存储三个成员。
构造函数获取一个上下文,从中获取一个 SensorManager 实例来设置事件侦听。剩下的代码相当于我们在第 4 章中所做的。请注意,如果没有安装加速度计,处理器将很乐意在其整个生命周期内在所有轴上返回零加速度。因此,我们不需要任何额外的错误检查或异常抛出代码。
接下来的两个方法,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 开发者,我们可能遇到的最糟糕的事情是什么?世界停止垃圾收集!如果你查看第 3 章中的输入接口定义,你会发现 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 实例并回收它,以避免垃圾收集器出现问题。这个类在几个地方很有用。请注意,当从池中取出重用的对象时,必须小心地完全重新初始化它们。
键盘处理程序:上,上,下,下,左,右。。。
键盘处理程序必须完成几项任务。首先,它必须与接收键盘事件的视图相连接。接下来,它必须为轮询存储每个键的当前状态。它还必须保留一个我们在第 3 章中为基于事件的输入处理设计的 KeyEvent 实例列表。最后,它必须正确地同步一切,因为它将在 UI 线程上接收事件,同时从我们的主游戏循环中轮询,这是在不同的线程上执行的。这是很大的工作量!作为复习,我们将向您展示我们在第 3 章的中定义的作为输入接口一部分的 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 列表会很快填满,并且没有对象返回到池中。只要我们记住这一点,问题是可以避免的。
触摸处理器
现在是时候考虑碎片化了。在第 4 章中,我们透露了多点触控仅在高于 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 接口,该接口用于向视图注册处理程序。接口的方法对应于第 3 章中定义的输入接口的相应方法。前三个用于轮询特定指针 ID 的状态,最后一个用于获取用来执行基于事件的输入处理的触摸事件。注意,轮询方法采用指针 id,它可以是任何数字,由触摸事件给出。
SingleTouchHandler 类
在我们的单触处理程序中,我们忽略除零以外的任何 id。概括地说,我们将回忆一下在第 3 章中定义的 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()方法看起来和我们在第 4 章中的测试例子一样吓人。然而,我们需要做的就是将测试代码与我们的事件池和同步结合起来,这一点我们已经详细讨论过了。与 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;
我们首先让这个类实现在第 3 章中定义的输入接口。这就引出了三个成员: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:双彩虹
是时候回到我们最喜爱的话题,图形编程了。在第 3 章中,我们定义了两个接口,分别叫做 Graphics 和 Pixmap。现在,我们将根据你在第 4 章中学到的东西来实现它们。然而,有一件事我们还没有考虑:如何处理不同的屏幕尺寸和分辨率。
处理不同的屏幕尺寸和分辨率
Android 从 1.6 版本开始就支持不同的屏幕分辨率。它可以处理从 240×320 像素到 1920×1080 的全高清电视分辨率。在第 4 章中,我们讨论了不同屏幕分辨率和物理屏幕尺寸的影响。例如,用绝对坐标和以像素为单位的尺寸绘图会产生意想不到的结果。图 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 枚举值,如第 3 章中定义的那样。此外,我们实现了 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 翻译成在第 4 章中使用的 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:循环,拉伸,循环,拉伸
这个类的名字应该给出未来的事情。在第 4 章中,我们讨论了使用 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()方法来启动。接下来,我们让活动全屏显示,就像我们在第 4 章的其他几个测试中所做的那样。在接下来的几行中,我们设置了我们的人工帧缓冲区。根据活动的方向,我们希望使用 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 先生。
有了所有这些样板代码,让我们专注于我们在这里的目的:编写游戏!*
版权属于:月萌API www.moonapi.com,转载请注明出处