安卓游戏编程示例(四)
原文:
zh.annas-archive.org/md5/B228CC957519C7ABCD7559EDEA0B426A译者:飞龙
第九章:使用 OpenGL ES 2 以 60 FPS 实现小行星游戏
欢迎来到最终项目。在接下来的三章中,我们将使用 OpenGL ES 2 图形 API 构建一个类似小行星的游戏。如果你好奇 OpenGL ES 2 到底是什么,我们将在本章后面讨论其细节。
我们将构建一个非常简单但有趣且具有挑战性的游戏,可以在一次绘制和动画化数百个对象,即使在相当老旧的 Android 设备上也能实现。
使用 OpenGL,我们将把绘图效率提升到一个更高的层次,通过一些不太复杂的数学运算,我们的移动和碰撞检测将比之前的项目大大增强。
在本章结束时,我们将拥有一个基本的可工作的 OpenGL ES 2 引擎,以 60 FPS 或更高的帧率绘制我们简单但暂时静态的飞船到屏幕上。
提示
如果你从未见过或玩过 1979 年 11 月发布的 80 年代街机游戏(Asteroids),为什么不去看看它的克隆版或视频呢?
在www.freeasteroids.org/免费玩网络游戏。
在 YouTube 上观看www.youtube.com/watch?v=WYSupJ5r2zo。
让我们确切地讨论一下我们打算构建的内容。
小行星模拟器
我们的游戏设定在一个四个方向滚动的世界中,玩家可以在寻找小行星的同时穿越这个世界。世界将被一个矩形边界所包围,以防止小行星漂移得太远,这个边界也将成为玩家需要避开的另一个障碍。
游戏控制
我们将重新使用我们的InputController类,并进行一些简单的修改,甚至可以保持相同的按钮布局。然而,正如我们将看到的,我们将以与复古平台游戏非常不同的方式在屏幕上绘制按钮。此外,玩家将旋转飞船向左和向右 360 度,而不是左右走动。跳跃按钮将变成一个推进开关,以开启或关闭前进动力,而射击按钮将保持原样。我们还将保持暂停按钮在同一位置。
游戏规则
当小行星撞击边界时,它将反弹回游戏世界。如果玩家撞击边界,将损失一条生命,并且飞船将在屏幕中心重新生成。如果小行星撞击飞船,这将是致命的。
玩家将从三条生命开始,必须清除所有小行星模拟器中的小行星。抬头显示(HUD)将显示剩余小行星和生命的计数。如果玩家清除了所有小行星,下一波将会比上一波更多,并且它们的移动速度会稍快一些。每清除一波,玩家将获得一条额外的生命。
我们将在项目进行中实施这些规则。
介绍 OpenGL ES 2
OpenGL ES 2 是针对嵌入式系统的开放图形库(OpenGL)的第二个主要版本。它是桌面系统 OpenGL 在移动设备上的化身。
为什么使用它以及它是如何工作的?
OpenGL 作为一个本地进程运行,而不是像我们其他 Java 代码那样在 Dalvik 虚拟机上运行。这是它超级快速的一个原因。OpenGL ES API 消除了与本地代码交互的所有复杂性,而 OpenGL 本身在其本地代码库中也提供了非常高效和快速的算法。
第一版的 OpenGL 在 1992 年完成。重点是即使在那时,OpenGL 也使用了可以说最高效的代码和算法来绘制图形。现在,20 多年后,它一直在不断改进和完善,同时适配最新的图形硬件,包括移动端和桌面端。所有移动 GPU 制造商都专门设计硬件以兼容最新版本的 OpenGL ES。
因此,试图改进 OpenGL ES 可能是一项愚蠢的尝试。
提示
当专门为 Windows 设备开发时,还有另一个可行的图形 API 选项,即 DirectX。
第二版的亮点是什么?
第一版的 OpenGL ES 在当时的确令人印象深刻。我记得当我第一次在手机上玩 3D 射击游戏时,几乎从椅子上掉下来!现在这当然很常见。然而,与桌面版的 OpenGL 相比,OpenGL ES 1 有一个重大缺点。
OpenGL ES 1 有一个所谓的固定功能管线。要绘制的几何图形输入到 GPU 中,它就会被绘制,但任何对单个像素的进一步操作都需要在 OpenGL ES 接管游戏帧的绘制之前进行。
现在,通过 OpenGL ES 2,我们可以使用所谓的可编程管线。也就是说,我们可以将图形发送出去进行绘制,同时我们还可以编写在 GPU 上运行的代码,这种代码能够独立操作每一个像素。这是一个非常强大的特性,尽管我们不会深入探讨它。
在 GPU 上运行的这段额外代码称为着色器程序。我们可以编写代码在所谓的顶点着色器中操作图形的几何(位置)。我们还可以编写代码,单独操作每一个像素的外观,这称为片断着色器。
注意
实际上,我们甚至可以比像素操作做得更好。片断不一定是像素。这取决于硬件和正在处理的图形的具体性质。它可以是一个以上的像素或子像素:屏幕硬件中组成一个像素的多个光线之一。
对于这种简单游戏而言,OpenGL ES 2 的缺点在于,即使你不会大量使用它们,也必须至少提供一个顶点和片断着色器。然而,正如我们将看到的,这并不困难。虽然我们不会深入探讨着色器,但我们将使用GL Shader Language (GLSL)编写一些着色器代码,并一窥它们所提供的可能性。
提示
如果可编程图形管线和着色器的力量让你兴奋到无法等待,那么我强烈推荐 Jacobo Rodríguez 编写的GLSL Essentials。
这本书探讨了桌面上的 OpenGL 着色器,对于任何具有基本编程知识并愿意学习不同语言(GLSL)的读者来说,都是高度可访问的,尽管它有一些与 Java 相似的语法。
我们将如何使用 OpenGL ES 2?
我们将如何使用 OpenGL ES 2?
在 OpenGL 中,一切都是点、线或三角形。此外,我们可以将颜色和纹理附加到这种基本几何图形上,并将这些元素组合起来制作出现在现代移动游戏中的复杂图形。
我们将使用各种类型的元素(点、线和三角形),这些元素统称为图元。
在这个项目中,我们将不使用纹理。幸运的是,未纹理化的图元的外观适合构建类似小行星的游戏。
除了图元,OpenGL 还使用矩阵。矩阵是一种进行算术的方法和结构。这种算术可以从非常简单的高中水平的计算来移动(转换)一个坐标,也可以进行更复杂的计算,将我们的游戏世界坐标转换为 GPU 可以使用的 OpenGL 屏幕坐标。
重点在于,无论是矩阵还是使用它们的方法,完全由 OpenGL API 提供。这意味着我们只需了解哪些方法可以进行哪些图形操作,无需关心背后可能复杂的数学运算(在 GPU 上进行的)。
学习 OpenGL 中的着色器、图元和矩阵的最佳方式是继续使用它们。
准备 OpenGL ES 2
首先,我们从Activity类开始,这和之前一样,是进入我们游戏的入口点。创建一个新项目,在应用名称字段中输入C9 Asteroids。选择手机和平板,然后在提示时选择空白活动。在活动名称字段中输入AsteroidsActivity。
提示
显然,你不必遵循我确切的命名选择,但只需记得在代码中进行小改动,以反映你自己的命名选择。
你可以从layout文件夹中删除activity_asteroids.xml。你也可以删除AsteroidsActivity.java文件中的所有代码。只需保留包声明。
将布局锁定为横屏
正如我们在前两个项目中做的那样,我们将确保游戏只在横屏模式下运行。我们将使我们的AndroidManifest.xml文件,强制我们的AsteroidsActivity类以全屏运行,并将其锁定为横屏方向。让我们进行这些更改:
-
现在打开
manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开它。 -
在
AndroidManifest.xml文件中,找到以下代码行:android:name=".AsteroidsActivity" -
立即输入或复制粘贴以下两行代码,使
PlatformActivity全屏运行,并将其锁定为横屏方向:android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:screenOrientation="landscape"
现在,我们可以继续使用 OpenGL 实现我们的 Asteroids 模拟器游戏。
活动
首先,我们有一个熟悉的Activity类。这里唯一的新事物是我们视图类的类型。我们声明了一个名为asteroidsView的成员,其类型为GLSurfaceView。这个类将为我们提供轻松访问 OpenGL 的途径。我们很快就会看到具体如何实现。注意,我们所做的一切就是通过传递Activity上下文和以通常方式获取的屏幕分辨率来初始化GLSurfaceView。按照所示实现AsteroidsActivity类:
package com.gamecodeschool.c9asteroids;
import android.app.Activity;
import android.graphics.Point;
import android.opengl.GLSurfaceView;
import android.os.Bundle;
import android.view.Display;
public class AsteroidsActivity extends Activity {
private GLSurfaceView asteroidsView;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Get a Display object to access screen details
Display display = getWindowManager().getDefaultDisplay();
// Load the resolution into a Point object
Point resolution = new Point();
display.getSize(resolution);
asteroidsView = new AsteroidsView
(this, resolution.x, resolution.y);
setContentView(asteroidsView);
}
@Override
protected void onPause() {
super.onPause();
asteroidsView.onPause();
}
@Override
protected void onResume() {
super.onResume();
asteroidsView.onResume();
}
}
接下来,我们将看到一些 OpenGL 代码。
视图
在这里,我们将实现GLSurfaceView类。实际上,这里并不是真正的动作发生的地方,但它确实允许我们附加一个 OpenGL 渲染器。这是一个实现了Renderer接口的类。除了这个关键的Renderer之外,GLSurfaceView类还允许我们覆盖onTouchListener方法,这将允许我们以与前一个项目中SurfaceView相同的方式来检测玩家输入。
注意
Android Studio 不会自动导入甚至建议所有必需的 OpenGL 导入。因此,我在代码清单中包含了一些类的所有导入。此外,你会注意到我们有时使用静态导入。这将使代码更具可读性。
在下面的代码中,我们声明并初始化了一个即将实现的GameManager类型的新对象。我们通过调用setEGLContextClientVersion(2)将 OpenGL 版本设置为 2,并通过调用setRenderer()并传入我们的GameManager对象来设置我们关键的渲染器对象。创建一个名为AsteroidsView的新类,并按以下方式实现它:
import android.content.Context;
import android.opengl.GLSurfaceView;
public class AsteroidsView extends GLSurfaceView{
GameManager gm;
public AsteroidsView(Context context, int screenX, int screenY) {
super(context);
gm = new GameManager(screenX, screenY);
// Which version of OpenGl we are using
setEGLContextClientVersion(2);
// Attach our renderer to the GLSurfaceView
setRenderer(new AsteroidsRenderer(gm));
}
}
现在,我们可以看看我们的GameManager类涉及到哪些内容。
一个管理我们游戏的类
这个类将控制诸如玩家所在的关卡、生命数量以及游戏世界的整体大小之类的事情。随着项目的进行,它会有一些变化,但与之前项目中的LevelManager和PlayerState类的综合深度相比,它将保持相当简单,尽管它实际上取代了这两个类。
在下面的代码中,我们声明了int类型的成员来保存游戏世界的宽度和高度;我们可以根据需要将其设置得更大或更小。我们使用布尔值playing来跟踪游戏的状态。
GameManager类还需要知道屏幕的宽度和高度(以像素为单位),这个信息是在AsteroidsView类中初始化对象时传递给构造函数的。
也请注意metresToShowX和metresToShowY成员变量。这些可能听起来很熟悉,因为它们来自上一个项目的Viewport类。这些变量将被用于完全相同的事情:定义游戏世界的当前可查看区域。然而这次,OpenGL 将负责在绘制之前裁剪哪些对象(使用矩阵)。我们很快就会看到这是在哪里发生的。
注意
请注意,尽管 OpenGL 负责裁剪和缩放我们想要显示的游戏世界区域,但它对每帧更新哪些对象没有任何影响。然而,正如我们将要看到的,这正是我们想要的游戏效果,因为即使对象在屏幕外,我们也希望所有对象每帧都能更新自己。因此,这个游戏不需要Viewport类。
最后,我们需要一个便捷的方式来暂停和继续游戏,我们通过switchPlayingStatus方法提供这个功能。创建一个名为GameManager的新类,并按照所示实现它:
public class GameManager {
int mapWidth = 600;
int mapHeight = 600;
private boolean playing = false;
// Our first game object
SpaceShip ship;
int screenWidth;
int screenHeight;
// How many metres of our virtual world
// we will show on screen at any time.
int metresToShowX = 390;
int metresToShowY = 220;
public GameManager(int x, int y){
screenWidth = x;
screenHeight = y;
}
public void switchPlayingStatus() {
playing = !playing;
}
public boolean isPlaying(){
return playing;
}
}
现在我们可以第一次了解这些强大的着色器以及我们将如何管理它们。
管理简单的着色器
应用程序可以有许多着色器。然后我们可以将不同的着色器附加到不同的游戏对象上,以创建所需的效果。
在这个游戏中,我们只会有一个顶点着色器和一个片段着色器。然而,当你了解到如何将着色器附加到图元上时,你会发现拥有更多着色器是非常简单的。
-
首先,我们需要在 GPU 中执行的着色器的代码。
-
然后,我们需要编译那段代码。
-
最后,我们需要将两个编译后的着色器链接成一个 GL 程序。
当我们实现这个下一个简单的类时,我们将看到如何将这个功能打包到一个方法调用中,这个调用可以由游戏中的对象发出,并将准备运行的 GL 程序返回给游戏对象。在本章后面构建我们的GameObject类时,我们将看到如何使用这个 GL 程序。
让我们在一个新类中实现必要的三个步骤。创建一个新类,并将其命名为GLManager。添加如下所示的静态导入:
import static android.opengl.GLES20.GL_FRAGMENT_SHADER;
import static android.opengl.GLES20.GL_VERTEX_SHADER;
import static android.opengl.GLES20.glAttachShader;
import static android.opengl.GLES20.glCompileShader;
import static android.opengl.GLES20.glCreateProgram;
import static android.opengl.GLES20.glCreateShader;
import static android.opengl.GLES20.glLinkProgram;
import static android.opengl.GLES20.glShaderSource;
接下来,我们将添加一些公共静态最终成员变量,我们可以在本章后面的GameObject类中使用它们。虽然我们将在使用它们时确切地看到它们是如何工作的,但这里有一个快速预览解释。
COPONENTS_PER_VERTEX是用于表示构成游戏对象的图元中的单个顶点(点)的值数量。如您所见,我们将这个值初始化为三个坐标:x,y和z。
我们还有FLOAT_SIZE,它被初始化为4。这是 Java 浮点数的字节数。正如我们很快将看到的,OpenGL 喜欢所有传入它的图元以ByteBuffer的形式。我们需要确保精确地知道在ByteBuffer中的每个信息片段的位置。
接下来,我们声明STRIDE并将其初始化为COMPONENTS_PER_VERTEX * FLOAT_SIZE。由于 OpenGL 使用浮点类型来处理几乎所有其工作的数据,STRIDE现在等于表示单个物体顶点的数据大小(以字节为单位)。请将这些成员添加到类的顶部:
public class GLManager {
// Some constants to help count the number of bytes between
// elements of our vertex data arrays
public static final int COMPONENTS_PER_VERTEX = 3;
public static final int FLOAT_SIZE = 4;
public static final int STRIDE =
(COMPONENTS_PER_VERTEX)
* FLOAT_SIZE;
public static final int ELEMENTS_PER_VERTEX = 3;// x,y,z
GLSL 是一种自身的语言,并且它也有自己的类型,这些类型的变量可以被利用。在这里,我们声明并初始化一些字符串,我们可以使用它们在代码中更清晰地引用这些变量。
这些类型的讨论超出了本书的范围,但简单来说,它们将代表一个矩阵(u_matrix)、一个位置(a_position)和一个颜色(u_Color)。我们很快将在我们的着色器代码中看到这些变量实际的 GLSL 类型。
在字符串之后,我们声明了三个int类型。这三个公共静态(但不是最终的)成员将用于存储我们着色器中同名类型的位置。这使得我们可以在给 OpenGL 最终的绘图指令之前,在着色器程序中操作这些值。
// Some constants to represent GLSL types in our shaders
public static final String U_MATRIX = "u_Matrix";
public static final String A_POSITION = "a_Position";
public static final String U_COLOR = "u_Color";
// Each of the above constants also has a matching int
// which will represent its location in the open GL glProgram
public static int uMatrixLocation;
public static int aPositionLocation;
public static int uColorLocation;
最后,我们来到了打包在字符串中的顶点着色器 GLSL 代码。注意,我们声明了一个名为u_Matrix的统一变量,类型为mat4,以及一个类型为属性vec4的a_Position。稍后在我们GameObject类中,我们将看到如何获取这些变量的位置,以便我们可以从 Java 代码中为它们传递值。
以void main()开始的代码行是实际着色器代码执行的地方。注意gl_position被分配了我们刚才声明的两个变量的乘积值。同时gl_PointSize被分配了3.0的值。这将是我们绘制所有点图元的大小。在之前的代码块之后,输入顶点着色器的代码:
// A very simple vertexShader glProgram
// that we can define with a String
private static String vertexShader =
"uniform mat4 u_Matrix;" +
"attribute vec4 a_Position;" +
"void main()" +
"{" +
"gl_Position = u_Matrix * a_Position;" +
"gl_PointSize = 3.0;"+
"}";
接下来,我们将实现片元着色器。这里发生了一些事情。首先,以precision mediump float开头的行告诉 OpenGL 以中等精度(因此也是中等速度)进行绘制。然后我们可以看到我们的变量u_Color被声明为统一类型vec4。我们很快会在GameObject类中看到如何将color值传递给这个变量。
当在void main()开始执行时,我们只需将u_Color分配给gl_FragColor。因此,无论分配给u_Color什么颜色,所有片段都将具有那个颜色。在片元着色器之后,我们声明了一个名为program的int,它将作为我们 GL 程序的句柄。
在前面的代码块之后,输入片元着色器的代码:
// A very simple vertexShader glProgram
// that we can define with a String
private static String vertexShader =
"uniform mat4 u_Matrix;" +
"attribute vec4 a_Position;" +
"void main()" +
"{" +
"gl_Position = u_Matrix * a_Position;" +
"gl_PointSize = 3.0;"+
"}";
这是一个获取器方法,它返回 GL 程序的句柄:
public static int getGLProgram(){
return program;
}
下一个方法可能看起来复杂,但它所做的只是将一个编译并链接的程序返回给调用者。它通过使用compileVertexShader()和compileFragmentShader()作为参数调用 OpenGL 的linkProgram方法来实现。接下来,我们看到这两个新方法,它们只需要使用代表着色器类型的 OpenGL 常量以及包含匹配着色器 GLSL 代码的适当字符串来调用我们的方法compileShader()。
将我们刚刚讨论的三个方法输入到GLManager类中:
public static int buildProgram(){
// Compile and link our shaders into a GL glProgram object
return linkProgram(compileVertexShader(),compileFragmentShader());
}
private static int compileVertexShader() {
return compileShader(GL_VERTEX_SHADER, vertexShader);
}
private static int compileFragmentShader() {
return compileShader(GL_FRAGMENT_SHADER, fragmentShader);
}
现在我们来了解当调用方法compileShader()时会发生什么。首先,我们会根据type参数创建一个着色器的句柄。然后,我们将该句柄和代码传递给glShaderSource()。最后,我们使用glCompileShader()编译着色器,并将句柄返回给调用方法:
private static int compileShader(int type, String shaderCode) {
// Create a shader object and store its ID
final int shader = glCreateShader(type);
// Pass in the code then compile the shader
glShaderSource(shader, shaderCode);
glCompileShader(shader);
return shader;
}
现在我们可以看到这个过程最后一步。我们使用glCreateProgram()创建一个空程序。然后依次使用glAttachShader()附加每个编译后的着色器,并最终使用glLinkProgram()将它们链接成一个我们可以实际使用的程序:
private static int linkProgram(int vertexShader, int fragmentShader) {
// A handle to the GL glProgram -
// the compiled and linked shaders
program = glCreateProgram();
// Attach the vertex shader to the glProgram.
glAttachShader(program, vertexShader);
// Attach the fragment shader to the glProgram.
glAttachShader(program, fragmentShader);
// Link the two shaders together into a glProgram.
glLinkProgram(program);
return program;
}
}// End GLManager
注意我们创建了一个程序,并且可以通过其句柄和getProgram方法访问它。我们还可以访问我们创建的所有那些公共静态成员,因此我们将能够从 Java 代码中调整着色器程序中的变量。
游戏的主循环——渲染器
现在我们将看到代码的核心部分将如何进行。创建一个名为AsteroidsRenderer的新类。这是我们附加到GLSurfaceView的渲染器类。按照如下方式添加导入语句,注意其中有一些是静态的:
import android.graphics.PointF;
import android.opengl.GLSurfaceView.Renderer;
import android.util.Log;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
import static android.opengl.GLES20.GL_COLOR_BUFFER_BIT;
import static android.opengl.GLES20.glClear;
import static android.opengl.GLES20.glClearColor;
import static android.opengl.GLES20.glViewport;
import static android.opengl.Matrix.orthoM;
现在我们将构建这个类。首先要注意的是,我们之前提到过这个类实现了Renderer,因此我们需要重写三个方法。它们是onSurfaceCreated()、onSurfaceChanged()和onDrawFrame()。此外,我们将在该类中最初添加一个构造函数来设置一切,一个createObjects方法,我们最终将在其中初始化所有游戏对象,一个update方法,我们将在其中每帧更新所有对象,以及一个draw方法,我们将在其中每帧绘制所有对象。
我们将在实现每个方法时探讨和解释它,我们还将看到我们的方法如何融入到 OpenGL 渲染器系统中,该系统决定了这个类的流程。
首先,有一些成员变量值得我们仔细查看。
布尔值debugging将被用来切换控制台输出的开启和关闭。frameCounter、averageFPS和fps变量不仅将被用来检查我们达到的帧率,而且还将被用来传递给我们的游戏对象,这些对象将根据每帧的流逝时间更新自己。
我们第一个真正有趣的变量是浮点数数组viewportMatrix。顾名思义,它将保存一个 OpenGL 可以用来计算到我们游戏世界的视口的矩阵。
我们有一个GameManager来保存对GameManager对象的引用,这是AsteroidsView在传递到这个类的构造函数中。最后,我们有两个PointF对象。
我们将在构造函数中初始化PointF对象,并将它们用于一些不同的事情,以避免在主游戏循环中取消引用任何对象。当垃圾收集器开始清理丢弃的对象时,即使是 OpenGL 也会减慢速度。避免召唤垃圾收集器将是整个游戏的目标。
在AsteroidsRenderer类的顶部输入成员变量:
public class AsteroidsRenderer implements Renderer {
// Are we debugging at the moment
boolean debugging = true;
// For monitoring and controlling the frames per second
long frameCounter = 0;
long averageFPS = 0;
private long fps;
// For converting each game world coordinate
// into a GL space coordinate (-1,-1 to 1,1)
// for drawing on the screen
private final float[] viewportMatrix = new float[16];
// A class to help manage our game objects
// current state.
private GameManager gm;
// For capturing various PointF details without
// creating new objects in the speed critical areas
PointF handyPointF;
PointF handyPointF2;
这是我们的构造函数,我们从参数初始化GameManager引用,并创建两个方便的PointF对象以备使用:
public AsteroidsRenderer(GameManager gameManager) {
gm = gameManager;
handyPointF = new PointF();
handyPointF2 = new PointF();
}
这是第一个重写的方法。每次创建带有附加渲染器的GLSurfaceView类时都会调用它。我们调用glClearColor()来设置每次 OpenGL 清除屏幕时使用的颜色。然后我们使用GLManager.buildProgram()方法构建我们的着色器程序,并调用我们将很快编写的createObjects方法。
@Override
public void onSurfaceCreated(GL10 glUnused, EGLConfig config) {
// The color that will be used to clear the
// screen each frame in onDrawFrame()
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
// Get GLManager to compile and link the shaders into an object
GLManager.buildProgram();
createObjects();
}
下一个重写的方法在onSurfaceCreated()之后调用一次,并且在屏幕方向改变时调用。在这里,我们调用glViewport()方法,告诉 OpenGL 将 OpenGL 坐标系统映射到哪个像素坐标上。
OpenGL 坐标系统与我们之前在两个项目中习惯处理的像素坐标有很大不同。屏幕的中心是 0,0,左下角是-1,右上角是 1。
前面的情况由于大多数屏幕不是正方形而进一步复杂化,但-1 到 1 的范围必须同时表示x和y轴。幸运的是,我们的glViewport()已经为我们处理了这个问题。
在这个方法中最后我们看到的是调用orthoM方法,将我们的viewportMatrix作为第一个参数。OpenGL 现在将准备使用viewportMatrix。orthoM()方法创建一个矩阵,将坐标转换为正交视图。如果我们的坐标是三维的,它将使所有物体看起来距离相同。由于我们正在制作一个二维游戏,这也适合我们。
输入onSurfaceChanged方法的代码:
@Override
public void onSurfaceChanged(GL10 glUnused, int width, int height) {
// Make full screen
glViewport(0, 0, width, height);
/*
Initialize our viewport matrix by passing in the starting
range of the game world that will be mapped, by OpenGL to
the screen. We will dynamically amend this as the player
moves around.
The arguments to setup the viewport matrix:
our array,
starting index in array,
min x, max x,
min y, max y,
min z, max z)
*/
orthoM(viewportMatrix, 0, 0,
gm.metresToShowX, 0,
gm.metresToShowY, 0f, 1f);
}
这是我们的createObjects方法,如您所见,我们创建了一个SpaceShip类型的对象,并将地图的高度和宽度传递给构造函数。我们将在本章后面构建SpaceShip类及其父类GameObject。输入createObjects方法:
private void createObjects() {
// Create our game objects
// First the ship in the center of the map
gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2);
}
这是重写的onDrawFrame方法。系统会连续调用它。我们可以通过设置渲染模式来控制何时调用它,当我们把AsteroidsRenderer附加到视图上,但默认的 OpenGL 控制连续调用正是我们所需要的。
我们将startFrameTime设置为当前的系统时间。然后,如果isPlaying()返回true,我们调用即将实现的update方法。然后,我们调用draw(),这将告诉我们的所有对象绘制自己。
然后,我们更新timeThisFrame和fps,可以选择在每 100 帧输出一次平均每秒帧数,如果我们正在调试的话。
现在我们知道 OpenGL 每秒最多可以调用onDrawFrame()数百次。我们将条件性地每次调用我们的update方法,以及调用我们的draw方法。除了实际的draw和update方法本身,我们已经有效地实现了游戏循环。
向类中添加onDrawFrame方法:
@Override
public void onDrawFrame(GL10 glUnused) {
long startFrameTime = System.currentTimeMillis();
if (gm.isPlaying()) {
update(fps);
}
draw();
// Calculate the fps this frame
// We can then use the result to
// time animations and more.
long timeThisFrame = System.currentTimeMillis() - startFrameTime;
if (timeThisFrame >= 1) {
fps = 1000 / timeThisFrame;
}
// Output the average frames per second to the console
if (debugging) {
frameCounter++;
averageFPS = averageFPS + fps;
if (frameCounter > 100) {
averageFPS = averageFPS / frameCounter;
frameCounter = 0;
Log.e("averageFPS:", "" + averageFPS);
}
}
}
这是我们的update方法,现在先留一个空体:
private void update(long fps) {
}
现在,我们来看看draw方法,它从onDrawFrame方法中每帧调用一次。在这里,我们将飞船的当前位置加载到我们的便捷PointF对象之一中。显然,由于我们还没有实现SpaceShip类,这个方法调用将产生错误。
在draw()中我们接下来要做的事情相当有趣。我们根据游戏世界中当前的位置以及分配给metresToShowX和metresToShowY的值修改我们的viewportMatrix。简单来说,我们将视口中心定位在飞船所在的位置,并向四个方向扩展我们希望显示的一半距离。记住,这会在每一帧发生,所以我们的视口将始终跟随玩家飞船。
接下来,我们调用glClear(),用onSurfaceCreated()中设置的颜色清除屏幕。在draw()中我们做的最后一件事是在我们的SpaceShip对象上调用draw方法。这意味着这与我们之前的两款游戏有一个非常基本的设计变化。
我们已经提到过这一点,但在这里我们可以看到它的实际应用:每个对象将绘制自己。同时,注意我们传递了新配置的viewportMatrix。
输入draw方法的代码:
private void draw() {
// Where is the ship?
handyPointF = gm.ship.getWorldLocation();
// Modify the viewport matrix orthographic projection
// based on the ship location
orthoM(viewportMatrix, 0,
handyPointF.x - gm.metresToShowX / 2,
handyPointF.x + gm.metresToShowX / 2,
handyPointF.y - gm.metresToShowY / 2,
handyPointF.y + gm.metresToShowY / 2,
0f, 1f);
// Clear the screen
glClear(GL_COLOR_BUFFER_BIT);
// Start drawing!
// Draw the ship
gm.ship.draw(viewportMatrix);
}
}
现在,我们可以构建我们的GameObject超类,紧接着是它的第一个子类SpaceShip。我们将看到这些对象如何设法使用 OpenGL 来绘制自己。
构建一个对 OpenGL 友好的GameObject超类
让我们直接进入代码。正如我们将看到的,这个GameObject将与之前项目中的GameObject类有很多共同之处。最显著的区别将在于,这个最新的GameObject当然会使用 GL 程序的句柄、子类中的原始(顶点)数据以及viewportMatrix中的视口矩阵来绘制自己。
创建一个新类,将其命名为GameObject,并输入这些导入语句,再次注意其中一些是静态的:
import android.graphics.PointF;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import static android.opengl.GLES20.GL_FLOAT;
import static android.opengl.GLES20.GL_LINES;
import static android.opengl.GLES20.GL_POINTS;
import static android.opengl.GLES20.GL_TRIANGLES;
import static android.opengl.GLES20.glDrawArrays;
import static android.opengl.GLES20.glEnableVertexAttribArray;
import static android.opengl.GLES20.glGetAttribLocation;
import static android.opengl.GLES20.glGetUniformLocation;
import static android.opengl.GLES20.glUniform4f;
import static android.opengl.GLES20.glUniformMatrix4fv;
import static android.opengl.GLES20.glUseProgram;
import static android.opengl.Matrix.multiplyMM;
import static android.opengl.Matrix.setIdentityM;
import static android.opengl.Matrix.setRotateM;
import static android.opengl.Matrix.translateM;
import static android.opengl.GLES20.glVertexAttribPointer;
import static com.gamecodeschool.c9asteroids.GLManager.*;
有很多成员变量,其中许多是自解释的并已加上注释以刷新我们的记忆,但也有一些全新的变量。
例如,我们有一个enum来表示我们将要创建的每种类型的GameObject。这样做的原因是,我们将某些对象绘制为点,一些绘制为线,一个绘制为三角形。我们使用 OpenGL 的方式在不同类型的图元之间是一致的;因此,我们将代码捆绑到这个父类中。然而,最终绘制图元的调用取决于图元的类型。我们可以使用type变量在switch语句中执行正确的draw方法。
我们还有一个名为int numElements和numVertices的变量,用于记录构成任何给定GameObject的点数。我们将在后面的子类中设置这些值。
我们还有一个浮点数数组modelVertices,它将存储构成一个模型的全部顶点。
在GameObject类中输入第一组成员变量,并查看注释以刷新您的记忆或明确各种成员最终将用于什么:
public class GameObject {
boolean isActive;
public enum Type {SHIP, ASTEROID, BORDER, BULLET, STAR}
private Type type;
private static int glProgram =-1;
// How many vertices does it take to make
// this particular game object?
private int numElements;
private int numVertices;
// To hold the coordinates of the vertices that
// define our GameObject model
private float[] modelVertices;
// Which way is the object moving and how fast?
private float xVelocity = 0f;
private float yVelocity = 0f;
private float speed = 0;
private float maxSpeed = 200;
// Where is the object centre in the game world?
private PointF worldLocation = new PointF();
接下来,我们将添加另一组成员变量。首先,最值得注意的是,我们有一个名为vertices的FloatBuffer。我们知道,OpenGL 在本地代码中执行,而FloatBuffers是它喜欢消费数据的方式。我们将看到如何将所有顶点打包到这个FloatBuffer中。
我们还将使用GLManager类中的所有公共静态成员来帮助我们正确处理。
在 OpenGL 方面,可能第二个最有趣的新成员是我们还有另外三个浮点数数组,名为modelMatrix、viewportModelMatrix和rotateViewportModelMatrix。这些将帮助 OpenGL 精确地按照要求绘制GameObject类。我们将在到达本类的draw方法时详细检查它们是如何初始化和使用的。
我们还有一些成员变量,用于保存不同的角度和旋转速率。我们如何使用和更新这些变量,以便通知 OpenGL 我们对象的方向,我们很快就会看到:
// This will hold our vertex data that is
// passed into the openGL glProgram
// OPenGL likes FloatBuffer
private FloatBuffer vertices;
// For translating each point from the model (ship, asteroid etc)
// to its game world coordinates
private final float[] modelMatrix = new float[16];
// Some more matrices for Open GL transformations
float[] viewportModelMatrix = new float[16];
float[] rotateViewportModelMatrix = new float[16];
// Where is the GameObject facing?
private float facingAngle = 90f;
// How fast is it rotating?
private float rotationRate = 0f;
// Which direction is it heading?
private float travellingAngle = 0f;
// How long and wide is the GameObject?
private float length;
private float width;
现在我们实现构造函数。首先,我们检查是否之前已经编译过着色器,因为我们只需要做一次。如果我们没有,这就是if(glProgarm == -1)块内发生的事情。
我们调用setGLProgram(),然后传入glProgram参数调用glUseProgram()。我们需要做的就这么多,GLManager会处理其余的工作,我们的 OpenGL 程序就可以使用了。
然而,在我们继续之前,通过调用相应的方法(glGetUniformLocation()和glGetAttrtibuteLocation)来保存关键着色器变量的位置,这些位置在我们的 GL 程序中的位置。我们将在本类的draw方法中看到如何使用这些位置来操作着色器内的值。
最后,我们将isActive设置为true。将此方法输入到GameObject类中:
public GameObject(){
// Only compile shaders once
if (glProgram == -1){
setGLProgram();
// tell OpenGl to use the glProgram
glUseProgram(glProgram);
// Now we have a glProgram we need the locations
// of our three GLSL variables.
// We will use these when we call draw on the object.
uMatrixLocation = glGetUniformLocation(glProgram, U_MATRIX);
aPositionLocation = glGetAttribLocation(glProgram, A_POSITION);
uColorLocation = glGetUniformLocation(glProgram, U_COLOR);
}
// Set the object as active
isActive = true;
}
现在我们有一些 getters 和 setters,包括从AsteroidsRenderer中的draw方法调用的getWorldLocation(),以及setGLProgram()。这使用GLManager类的静态方法getGLProgram()来获取我们 GL 程序的句柄。
在GameObject类中输入所有这些方法:
public boolean isActive() {
return isActive;
}
public void setActive(boolean isActive) {
this.isActive = isActive;
}
public void setGLProgram(){
glProgram = GLManager.getGLProgram();
}
public Type getType() {
return type;
}
public void setType(Type t) {
this.type = t;
}
public void setSize(float w, float l){
width = w;
length = l;
}
public PointF getWorldLocation() {
return worldLocation;
}
public void setWorldLocation(float x, float y) {
this.worldLocation.x = x;
this.worldLocation.y = y;
}
下一个方法setVertices()是在准备由 OpenGL 绘制对象时的关键步骤。在我们的每个子类中,我们将构建一个浮点类型的数组来表示构成游戏对象形状的顶点。每个游戏对象在形状上显然都是不同的,但setVertices方法无需关注这些差异,它只需要数据。
正如我们在下一个代码块中看到的,该方法接收一个浮点数组作为参数。然后它将等于数组长度的元素数量存储在numElements中。请注意,元素的数量与它们表示的顶点数量不同。需要三个元素(x,y和z)来表示一个顶点。因此,我们可以通过将numElements除以ELEMENTS_PER_VERTEX来将正确的值存储在numVertices中。
现在,我们可以通过调用allocateDirect()并传入我们新初始化的变量以及FLOAT_SIZE来实际初始化我们的ByteBuffer。ByteOrder.nativeOrder方法只是检测特定系统的字节序,而asFloatBuffer()告诉ByteBuffer将要存储的数据类型。现在,我们可以通过调用vertices.put(modelVertices)将顶点数组存储到我们的顶点ByteBuffer中。这些数据现在可以传递给 OpenGL 了。
提示
如果你想了解更多关于字节序的信息,请查看这篇维基百科文章:
在GameObject类中输入setVertices方法:
public void setVertices(float[] objectVertices){
modelVertices = new float[objectVertices.length];
modelVertices = objectVertices;
// Store how many vertices and elements there is for future use
numElements = modelVertices.length;
numVertices = numElements/ELEMENTS_PER_VERTEX;
// Initialize the vertices ByteBuffer object based on the
// number of vertices in the ship design and the number of
// bytes there are in the float type
vertices = ByteBuffer.allocateDirect(
numElements
* FLOAT_SIZE)
.order(ByteOrder.nativeOrder()).asFloatBuffer();
// Add the ship into the ByteBuffer object
vertices.put(modelVertices);
}
现在我们来看看我们是如何实际绘制我们的ByteBuffer的内容的。乍一看,以下代码可能看起来很复杂,但当我们讨论ByteBuffer中的数据性质以及 OpenGL 绘制这些数据的步骤时,我们会发现这实际上相当直接。
由于我们还没有编写第一个GameObject子类的代码,有一个关键点需要指出。表示游戏对象形状的顶点是基于其自身的中心为零的。
OpenGL 坐标系统的中心是0,0,但为了明确起见,这与我们无关,这被称为模型空间。下一张图片展示了我们即将创建的飞船,在模型空间中的表示:
这些数据包含在我们的ByteBuffer中。这些数据不考虑方向(飞船或小行星是否旋转),不考虑在游戏世界中的位置,并且再次提醒,它与 OpenGL 坐标系统完全无关。
因此,在我们绘制ByteBuffer之前,我们需要转换这些数据,或者更准确地说,我们需要准备一个合适的矩阵,并将其与数据一起传递给 OpenGL,以便 OpenGL 知道如何使用或转换数据。
我将draw方法分成了六个部分来讲解我们是如何做到这一点的。请注意,我们的viewPort矩阵在我们的AsteroidsRenderer类的draw方法中准备,该方法以飞船的位置为中心,基于我们想要显示的游戏世界比例,并作为参数传入。
首先,我们调用glUseProgram()并传入我们程序的句柄。然后我们将ByteBuffer的内部指针设置为起始位置,使用vertices.position(0)。
glVertexAttributePointer方法使用我们的aPositionLocation变量以及我们的GLManager静态常量,当然还有vertices ByteBuffer,将我们的顶点与顶点着色器中的aPosition变量相关联。最后,对于这段代码,我们告诉 OpenGL 启用属性数组:
public void draw(float[] viewportMatrix){
// tell OpenGl to use the glProgram
glUseProgram(glProgram);
// Set vertices to the first byte
vertices.position(0);
glVertexAttribPointer(
aPositionLocation,
COMPONENTS_PER_VERTEX,
GL_FLOAT,
false,
STRIDE,
vertices);
glEnableVertexAttribArray(aPositionLocation);
现在,我们将矩阵投入使用。通过调用setIndentityM(),我们从modelMatrix数组中创建一个单位矩阵。
注意
正如我们将要看到的,我们将使用和组合相当多的矩阵。单位矩阵作为一个起点或容器,我们可以在其上构建一个矩阵,该矩阵结合了我们需要发生的所有变换。一种非常简单但并不完全准确的方式来考虑单位矩阵是,它就像数字 1。当你乘以一个单位矩阵时,它不会对和的其它部分造成任何改变。然而,这个答案对于继续方程的下一部分是正确的。如果这让你感到烦恼,并且你想了解更多,请查看关于矩阵和单位矩阵的以下快速教程。
矩阵:
单位矩阵:
然后,我们将新的modelMatrix传递给translateM方法。在数学术语中,translate 意为移动。仔细观察传递给translateM()的参数。我们正在传递物体的x和y世界坐标。这就是 OpenGL 知道物体位置的方式:
// Translate model coordinates into world coordinates
// Make an identity matrix to base our future calculations on
// Or we will get very strange results
setIdentityM(modelMatrix, 0);
// Make a translation matrix
/*
Parameters:
m matrix
mOffset index into m where the matrix starts
x translation factor x
y translation factor y
z translation factor z
*/
translateM(modelMatrix, 0, worldLocation.x, worldLocation.y, 0);
我们知道 OpenGL 有一个矩阵可以将我们的对象转换到它的世界位置。它还有一个ByteBuffer类,其中包含模型空间坐标,但它如何将转换后的模型空间坐标转换为使用 OpenGL 坐标系统绘制的视口呢?
它使用了视口矩阵,该矩阵每帧都会被修改并传入这个方法。我们需要做的就是使用multiplyMM()将viewportMatrix和最近转换的modelMatrix相乘。这个方法创建了组合或乘积矩阵,并将结果存储在viewportModelMatrix中:
// Combine the model with the viewport
// into a new matrix
multiplyMM(viewportModelMatrix, 0,
viewportMatrix, 0, modelMatrix, 0);
我们几乎完成了矩阵的创建。OpenGL 需要对ByteBuffer中的顶点进行的唯一其他可能的扭曲就是根据facingAngle参数旋转它们。
接下来,我们创建一个适合当前物体面向角度的旋转矩阵,并将结果重新存储在modelMatrix中。
然后,我们将新旋转的modelMatrix与我们的viewportModelMatrix组合或相乘,并将结果存储在rotateViewportModelMatrix中。这是我们最终要传入 OpenGL 系统的矩阵:
/*
Now rotate the model - just the ship model
Parameters
rm returns the result
rmOffset index into rm where the result matrix starts
a angle to rotate in degrees
x X axis component
y Y axis component
z Z axis component
*/
setRotateM(modelMatrix, 0, facingAngle, 0, 0, 1.0f);
// And multiply the rotation matrix into the model-viewport
// matrix
multiplyMM(rotateViewportModelMatrix, 0,
viewportModelMatrix, 0, modelMatrix, 0);
现在,我们使用glUniformMatrix4fv()方法传入矩阵,并在参数中使用uMatrixLocation变量(这是顶点着色器中与矩阵相关的变量的位置)和我们最终的矩阵。
我们还通过调用glUniform4f()并使用uColorLocation和一个 RGBT(红、绿、蓝、透明度)值来选择颜色。所有值都设置为 1.0,因此片段着色器将绘制白色。
// Give the matrix to OpenGL
glUniformMatrix4fv(uMatrixLocation, 1, false,
rotateViewportModelMatrix, 0);
// Assign a color to the fragment shader
glUniform4f(uColorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
最后,我们根据对象类型进行切换,并绘制点、线或三角形图元:
// Draw the point, lines or triangle
switch (type){
case SHIP:
glDrawArrays(GL_TRIANGLES, 0, numVertices);
break;
case ASTEROID:
glDrawArrays(GL_LINES, 0, numVertices);
break;
case BORDER:
glDrawArrays(GL_LINES, 0, numVertices);
break;
case STAR:
glDrawArrays(GL_POINTS, 0, numVertices);
break;
case BULLET:
glDrawArrays(GL_POINTS, 0, numVertices);
break;
}
} // End draw()
}// End class
现在我们已经掌握了GameObject类的基础知识,我们可以创建一个类来表示我们的飞船并在屏幕上绘制它。
飞船
这个类非常简单,尽管它会随着项目的发展而演变。构造函数接收游戏世界中起点的位置。我们使用GameObject类的方法设置飞船的类型和世界位置,并设置宽度和高度。
我们声明并初始化一些变量,以简化模型空间坐标的初始化,然后继续初始化一个浮点数组,其中包含三个顶点,这些顶点表示我们的飞船的三角形。请注意,这些值是基于x = 0和y = 0中心的。
接下来,我们只需调用setVertices(),GameObject就会准备好ByteBuffer供 OpenGL 使用:
public class SpaceShip extends GameObject{
public SpaceShip(float worldLocationX, float worldLocationY){
super();
// Make sure we know this object is a ship
// So the draw() method knows what type
// of primitive to construct from the vertices
setType(Type.SHIP);
setWorldLocation(worldLocationX,worldLocationY);
float width = 15;
float length = 20;
setSize(width, length);
// It will be useful to have a copy of the
// length and width/2 so we don't have to keep dividing by 2
float halfW = width / 2;
float halfL = length / 2;
// Define the space ship shape
// as a triangle from point to point
// in anti clockwise order
float [] shipVertices = new float[]{
- halfW, - halfL, 0,
halfW, - halfL, 0,
0, 0 + halfL, 0
};
setVertices(shipVertices);
}
}
最后,我们可以看到我们努力的成果。
以 60 + FPS 的速度绘制
通过三个简单的步骤,我们将能够看到我们的飞船:
-
在
GameManager成员变量中添加一个SpaceShip对象:private boolean playing = false; // Our first game object SpaceShip ship; int screenWidth; -
在
createObjects方法中添加对新的SpaceShip()的调用:private void createObjects() { // Create our game objects // First the ship in the center of the map gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2); } -
在
AsteroidsRenderer的draw方法中,添加每一帧绘制飞船的调用:// Start drawing! // Draw the ship gm.ship.draw(viewportMatrix);
运行游戏并查看输出:
视觉效果并不令人印象深刻,但在调试模式下,在老旧的三星 Galaxy S2 手机上输出到控制台时,它的运行帧数在 67 到 212 之间。
在整个项目中,我们的目标是在保持每秒 60 帧以上的情况下添加数百个对象。
提示
一位书籍评论者在 Nexus 5 上报告了每秒超过 1000 帧的速率!因此,如果你打算将此应用发布到 Google Play 商店,考虑采用最大帧率锁定策略以节省电池寿命将是非常值得的。
总结
设置绘图系统可能有些繁琐。然而,现在它已经完成,我们可以更轻松地快速生成新对象。我们只需定义类型和顶点,然后就可以轻松地绘制它们。
正是因为有了这些基础工作,下一章内容将更加视觉上令人满意。接下来,我们将创建闪烁的星星、游戏世界的边界、旋转移动的小行星、呼啸而过的子弹、以及一个 HUD(平视显示器),同时为太空飞船添加完整的控制和动作。
第十章:使用 OpenGL ES 2 进行移动和绘制
在本章中,我们将实现所有的图形、游戏玩法和移动。在 30 多页的内容中,我们将完成除了碰撞检测之外的所有内容。我们能完成这么多,是因为我们在上一章打下了基础。
首先,我们将在游戏世界周围绘制一个静态边界,然后是一些闪烁的星星,接着为我们的太空船添加移动以及一些子弹。在那之后,我们将快速添加玩家的控制,我们将在屏幕上飞快地移动。
我们还将通过实现带有一些新声音效果的SoundManager类来制造一些噪音。
完成这些后,我们将添加随机形状的小行星,这些小行星在旋转的同时穿过整个世界。
然后,我们可以添加一个 HUD 来突出屏幕的可触摸区域,并提供剩余玩家生命值和需要摧毁的小行星数量的统计。
绘制静态游戏边界
在这个简单的类中,我们定义了四组点,它们将代表四条线。毫不奇怪,GameObject类将使用这些点作为线的端点来绘制边界。
在构造函数中,也就是类的全部内容,我们通过调用setType()设置类型,将世界位置设置为地图中心,以及将height和width设置为整个地图的高度和宽度。
然后,我们在一个 float 数组中定义四条线,并调用setVertices()来准备一个FloatBuffer。
创建一个名为Border的新类,并添加以下代码:
public class Border extends GameObject{
public Border(float mapWidth, float mapHeight){
setType(Type.BORDER);
//border center is the exact center of map
setWorldLocation(mapWidth/2,mapHeight/2);
float w = mapWidth;
float h = mapHeight;
setSize(w, h);
// The vertices of the border represent four lines
// that create a border of a size passed into the constructor
float[] borderVertices = new float[]{
// A line from point 1 to point 2
- w/2, -h/2, 0,
w/2, -h/2, 0,
// Point 2 to point 3
w/2, -h/2, 0,
w/2, h/2, 0,
// Point 3 to point 4
w/2, h/2, 0,
-w/2, h/2, 0,
// Point 4 to point 1
-w/2, h/2, 0,
- w/2, -h/2, 0,
};
setVertices(borderVertices);
}
}
然后,我们可以像这样在GameManager中声明一个Border对象:
// Our game objects
SpaceShip ship;
Border border;
在AsteroidsRenderer的createObjects方法中这样初始化它:
// Create our game objects
// First the ship in the center of the map
gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2);
// The deadly border
gm.border = new Border(gm.mapWidth, gm.mapHeight);
现在,我们可以在AsteroidsRendrer类的draw方法中添加一行代码来绘制我们的边界:
gm.ship.draw(viewportMatrix);
gm.border.draw(viewportMatrix);
你现在可以运行游戏了。如果你想实际看到边界,可以将我们初始化飞船的位置改到靠近边界的地方。记住,在draw方法中,我们将视口围绕飞船居中。要看到边界,将SpaceShip类中的这一行改为这样:
setWorldLocation(10,10);
运行游戏看看效果。
改回这一行:
setWorldLocation(worldLocationX,worldLocationY);
现在,我们将在边框内填充星星。
闪烁的星星
我们将使边界更加动态,而不仅仅是静态的。在这里,我们将向一个简单的Star类中添加一个update方法,该方法可以用来随机地打开或关闭星星。
我们将类型设置为normal,并在边界的范围内为星星创建一个随机位置,并像往常一样调用setWorldLocation()。
星星将被绘制成点,因此我们的顶点数组将只包含模型空间 0,0,0 的一个顶点。然后,我们像往常一样调用setVertices()。
创建一个新类,命名为Star,并输入我们讨论过的代码:
public class Star extends GameObject{
// Declare a random object here because
// we will use it in the update() method
// and we don't want GC to have to keep clearing it up
Random r;
public Star(int mapWidth, int mapHeight){
setType(Type.STAR);
r = new Random();
setWorldLocation(r.nextInt(mapWidth),r.nextInt(mapHeight));
// Define the star
// as a single point
// in exactly the coordinates as its world location
float[] starVertices = new float[]{
0,
0,
0
};
setVertices(starVertices);
}
这是我们的Star类的update方法。正如我们所见,每一帧都有千分之一的机会让星星改变其状态。为了更多闪烁,请使用较低的种子值,为了减少闪烁,请使用较高的种子值。
public void update(){
// Randomly twinkle the stars
int n = r.nextInt(1000);
if(n == 0){
// Switch on or off
if(isActive()){
setActive(false);
}else{
setActive(true);
}
}
}
然后,我们在GameManager中声明一个Star数组成员,以及一个额外的int变量来控制我们想要绘制的星星数量,如下所示:
// Our game objects
SpaceShip ship;
Border border;
Star[] stars;
int numStars = 200;
在AsteroidsRenderer的createObjects方法中初始化Star对象的数组,如下所示:
// The deadly border
gm.border = new Border(gm.mapWidth, gm.mapHeight);
// Some stars
gm.stars = new Star[gm.numStars];
for (int i = 0; i < gm.numStars; i++) {
// Pass in the map size so the stars no where to spawn
gm.stars[i] = new Star(gm.mapWidth, gm.mapHeight);
}
现在,我们可以在AsteroidsRenderer类的draw方法中添加以下代码行来绘制我们的星星。注意,我们首先绘制星星,因为它们在背景中。
// Start drawing!
// Some stars
for (int i = 0; i < gm.numStars; i++) {
// Draw the star if it is active
if(gm.stars[i].isActive()) {
gm.stars[i].draw(viewportMatrix);
}
}
gm.ship.draw(viewportMatrix);
gm.border.draw(viewportMatrix);
当然,为了使它们闪烁,我们从AsteroidsRenderer类的update方法中调用它们的update方法,如下所示:
private void update(long fps) {
// Update (twinkle) the stars
for (int i = 0; i < gm.numStars; i++) {
gm.stars[i].update();
}
}
你现在可以运行游戏了:
让飞船生动起来
首先,我们需要为我们的GameObject类添加更多功能。我们在GameObject中这样做,因为子弹和行星与飞船共享许多惊人的相似之处。
我们需要一堆获取器和设置器来获取和设置旋转速率、行驶角度和面向角度。向GameObject类添加以下方法:
public void setRotationRate(float rotationRate) {
this.rotationRate = rotationRate;
}
public float getTravellingAngle() {
return travellingAngle;
}
public void setTravellingAngle(float travellingAngle) {
this.travellingAngle = travellingAngle;
}
public float getFacingAngle() {
return facingAngle;
}
public void setFacingAngle(float facingAngle) {
this.facingAngle = facingAngle;
}
现在,我们添加一个move方法,该方法根据当前的每秒帧数调整对象的x和y坐标以及facingAngle。添加move方法:
void move(float fps){
if(xVelocity != 0) {
worldLocation.x += xVelocity / fps;
}
if(yVelocity != 0) {
worldLocation.y += yVelocity / fps;
}
// Rotate
if(rotationRate != 0) {
facingAngle = facingAngle + rotationRate / fps;
}
}
为了完善我们对GameObject类的添加,为速度、速度和最大速度添加以下获取器和设置器:
public float getxVelocity() {
return xVelocity;
}
public void setxVelocity(float xVelocity) {
this.xVelocity = xVelocity;
}
public float getyVelocity() {
return yVelocity;
}
public void setyVelocity(float yVelocity) {
this.yVelocity = yVelocity;
}
public float getSpeed() {
return speed;
}
public void setSpeed(float speed) {
this.speed = speed;
}
public float getMaxSpeed() {
return maxSpeed;
}
public void setMaxSpeed(float maxSpeed) {
this.maxSpeed = maxSpeed;
}
我们可以为SpaceShip类添加一些内容。向SpaceShip类添加以下三个成员,以控制玩家的飞船是否在转向或向前移动:
boolean isThrusting;
private boolean isPressingRight = false;
private boolean isPressingLeft = false;
现在,在SpaceShip构造函数内部,让我们设置飞船的最大速度。我在现有代码中突出了新的一行代码:
setSize(width, length);
setMaxSpeed(150);
// It will be useful to have a copy of the
接下来,在SpaceShip类中,我们添加一个update方法,首先根据isThrusting是真是假来增加或减少速度。
public void update(long fps){
float speed = getSpeed();
if(isThrusting) {
if (speed < getMaxSpeed()){
setSpeed(speed + 5);
}
}else{
if(speed > 0) {
setSpeed(speed - 3);
}else {
setSpeed(0);
}
}
然后,我们根据角度、船舶面向的方向以及速度来设置x和y速度。
注意
我们使用速度乘以船舶面向角度的余弦值来设置在x轴上的速度。这样做有效是因为余弦函数是一个完美的变量,当船舶分别面向左或右时,它会返回-1 或 1 的值;当船舶正好指向上或下时,该变量返回精确的 0 值。它也在两者之间的角度返回精细的值。正弦函数在y轴上以完全相同的方式工作。代码看起来有些复杂,这是因为我们需要将角度转换为弧度,并且必须给我们的facingAngle加上 90 度,因为 0 度是指向三点钟方向。这个事实不利于我们按照现在的方式在x, y平面上使用它,所以我们将其修改为 90 度,船舶就能如预期般移动了。有关这一工作原理的更多信息,请查看以下教程:
gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/
setxVelocity((float)
(speed* Math.cos(Math.toRadians(getFacingAngle() + 90))));
setyVelocity((float)
(speed* Math.sin(Math.toRadians(getFacingAngle() + 90))));
现在,我们根据玩家是向左还是向右转动来设置旋转速度。最后,我们调用move()以使所有更新生效。
if(isPressingLeft){
setRotationRate(360);
}
else if(isPressingRight){
setRotationRate(-360);
}else{
setRotationRate(0);
}
move(fps);
}
现在,我们需要添加一个pullTrigger方法,目前我们只需返回true。我们还提供了三种方法供未来的InputController调用,触发update方法以进行各种更改。
public boolean pullTrigger() {
//Try and fire a shot
// We could control rate of fire from here
// But lets just return true for unrestricted rapid fire
// You could remove this method and any code which calls it
return true;
}
public void setPressingRight(boolean pressingRight) {
isPressingRight = pressingRight;
}
public void setPressingLeft(boolean pressingLeft) {
isPressingLeft = pressingLeft;
}
public void toggleThrust() {
isThrusting = ! isThrusting;
}
我们已经在每一帧中绘制了飞船,但我们需要在AsteroidsRenderer类的update方法中添加一行代码。添加这行代码以调用SpaceShip类的update方法:
// Update (twinkle) the stars
for (int i = 0; i < gm.numStars; i++) {
gm.stars[i].update();
}
// Run the ship,s update() method
gm.ship.update(fps);
显然,在我们添加玩家控制之前,我们实际上无法移动。让我们快速向游戏中添加一些子弹。然后,我们将添加声音和控制,这样我们就可以看到和听到我们添加的酷炫新功能。
快速连发子弹
自 20 世纪 70 年代的 Pong 游戏以来,我就沉迷于游戏,记得当一位朋友在家中拥有一台太空侵略者游戏机大约一周时,我是多么兴奋。尽管真正让小行星比太空侵略者好的地方在于,你可以多快地进行射击。秉承这一传统,我们将制作一个令人满意的快速子弹流。
创建一个名为Bullet的新类,它有一个顶点,并将被绘制成一个点。注意,我们还声明并初始化了一个inFlight布尔值。
public class Bullet extends GameObject {
private boolean inFlight = false;
public Bullet(float shipX, float shipY) {
super();
setType(Type.BULLET);
setWorldLocation(shipX, shipY);
// Define the bullet
// as a single point
// in exactly the coordinates as its world location
float[] bulletVertices = new float[]{
0,
0,
0
};
setVertices(bulletVertices);
}
接下来,我们有一个shoot方法,它将子弹的facingAngle设置为飞船的facingAngle。这将导致子弹在按下开火按钮时沿飞船面向的方向移动。我们还设置inFlight为真,并查看在update方法中是如何使用它的。最后,我们将速度设置为300。
我们还添加了一个resetBullet方法,它将子弹设置在飞船内部并取消其速度和速度。这让我们对如何实现我们的子弹有了线索。子弹将在飞船内部不可见,直到它们被发射。
public void shoot(float shipFacingAngle){
setFacingAngle(shipFacingAngle);
inFlight = true;
setSpeed (300);
}
public void resetBullet(PointF shipLocation){
// Stop moving if bullet out of bounds
inFlight = false;
setxVelocity(0);
setyVelocity(0);
setSpeed(0);
setWorldLocation(shipLocation.x, shipLocation.y);
}
public boolean isInFlight(){
return inFlight;
}
现在,我们根据子弹的facingAngle和速度移动子弹,但只有当inFlight为真时。否则,我们将子弹保留在飞船内部。然后,我们调用move()。
public void update(long fps, PointF shipLocation){
// Set the velocity if bullet in flight
if(inFlight){
setxVelocity((float)(getSpeed()*
Math.cos(Math.toRadians(getFacingAngle() + 90))));
setyVelocity((float)(getSpeed()*
Math.sin(Math.toRadians(getFacingAngle() + 90))));
}else{
// Have it sit inside the ship
setWorldLocation(shipLocation.x, shipLocation.y);
}
move(fps);
}
}
现在,我们有一个Bullet类,可以在GameManager类中声明一个数组,用来保存这一类型的多个对象。
int numStars = 200;
Bullet [] bullets;
int numBullets = 20;
在createObjects()中初始化它们,就在AsteroidsRenderer中上一节星星之后。注意我们是如何将它们在游戏世界中的位置初始化为飞船的中心。
// Some bullets
gm.bullets = new Bullet[gm.numBullets];
for (int i = 0; i < gm.numBullets; i++) {
gm.bullets[i] = new Bullet(
gm.ship.getWorldLocation().x,
gm.ship.getWorldLocation().y);
}
在update方法中更新它们,就在我们的闪烁星星之后。
// Update all the bullets
for (int i = 0; i < gm.numBullets; i++) {
// If not in flight they will need the ships location
gm.bullets[i].update(fps, gm.ship.getWorldLocation());
}
在draw方法中绘制它们,再次在星星之后。
for (int i = 0; i < gm.numBullets; i++) {
gm.bullets[i].draw(viewportMatrix);
}
子弹现在已准备好发射!
我们将添加一个SoundManager和InputController类,然后我们可以看到我们的飞船及其快速开火枪支的行动。
重用现有类
让我们快速将SoundManager和InputController类添加到这个项目中,因为它们只需要稍作调整就能满足我们这里的需求。
在AsteroidsView和AsteroidsRenderer类中都添加一个SoundManager和一个InputController对象的成员。
private InputController ic;
private SoundManager sm;
在AsteroidsView类的onCreate方法中初始化新对象,并像这样调用loadSound方法:
public AsteroidsView(Context context, int screenX, int screenY) {
super(context);
sm = new SoundManager();
sm.loadSound(context);
ic = new InputController(screenX, screenY);
gm = new GameManager(screenX, screenY);
同样在AsteroidsView中,向AsteroidsRenderer构造函数的调用中添加两个额外的参数,以传递对SoundManager和InputController对象的引用。
setEGLContextClientVersion(2);
setRenderer(new AsteroidsRenderer(gm,sm,ic));
现在,在AsteroidsRenderer构造函数中添加两个额外的参数,并像这样初始化两个新成员:
public AsteroidsRenderer(GameManager gameManager,
SoundManager soundManager, InputController inputController) {
gm = gameManager;
sm = soundManager;
ic = inputController;
handyPointF = new PointF();
handyPointF2 = new PointF();
}
在我们添加这两个类之前,你的 IDE 中会有错误。我们现在就来做这件事。
添加SoundManager类
SoundManager类的工作方式与上一个项目完全一样,所以这里没有什么新内容需要解释。
将下载包Chapter10/assets文件夹中的所有声音文件添加到项目的 assets 文件夹中。与最后两个项目一样,你可能需要在项目的.../app/src/main文件夹中创建 assets 文件夹。
提示
与往常一样,你可以使用提供的声音效果,或者创建自己的效果。
现在,向项目中添加一个名为SoundManager的新类。请注意,该类的功能与上一个项目完全相同,但代码不同仅仅是因为声音文件和相关变量的名称不同。将以下代码添加到SoundManager类中:
public class SoundManager {
private SoundPool soundPool;
private int shoot = -1;
private int thrust = -1;
private int explode = -1;
private int shipexplode = -1;
private int ricochet = -1;
private int blip = -1;
private int nextlevel = -1;
private int gameover = -1;
public void loadSound(Context context){
soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC,0);
try{
//Create objects of the 2 required classes
AssetManager assetManager = context.getAssets();
AssetFileDescriptor descriptor;
//create our fx
descriptor = assetManager.openFd("shoot.ogg");
shoot = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("thrust.ogg");
thrust = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("explode.ogg");
explode = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("shipexplode.ogg");
shipexplode = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("ricochet.ogg");
ricochet = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("blip.ogg");
blip = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("nextlevel.ogg");
nextlevel = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("gameover.ogg");
gameover = soundPool.load(descriptor, 0);
}catch(IOException e){
//Print an error message to the console
Log.e("error", "failed to load sound files");
}
}
public void playSound(String sound){
switch (sound){
case "shoot":
soundPool.play(shoot, 1, 1, 0, 0, 1);
break;
case "thrust":
soundPool.play(thrust, 1, 1, 0, 0, 1);
break;
case "explode":
soundPool.play(explode, 1, 1, 0, 0, 1);
break;
case "shipexplode":
soundPool.play(shipexplode, 1, 1, 0, 0, 1);
break;
case "ricochet":
soundPool.play(ricochet, 1, 1, 0, 0, 1);
break;
case "blip":
soundPool.play(blip, 1, 1, 0, 0, 1);
break;
case "nextlevel":
soundPool.play(nextlevel, 1, 1, 0, 0, 1);
break;
case "gameover":
soundPool.play(gameover, 1, 1, 0, 0, 1);
break;
}
}
}
我们现在可以从任何有对新类引用的地方调用playSound()。
添加InputController类
这与上一个项目中的处理方式相同,只是我们调用适当的PlayerShip方法,而不是 Bob 的。此外,当游戏暂停时,我们不会移动视口,因此无需在游戏暂停时以不同的方式处理屏幕触摸;这使得这个InputController更简单,更短。
在AsteroidsView类中添加onTouchEvent方法,以将处理触摸的责任传递给InputController:
@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
ic.handleInput(motionEvent, gm, sm);
return true;
}
添加一个名为InputController的新类,并添加以下代码,这些代码很直观,除了我们处理玩家发射子弹的方式。
我们声明一个成员int currentBullet,用于跟踪我们将要发射的下一个子弹,来自我们即将声明的数组。然后,当按下开火按钮时,我们可以计算子弹数量,并在数组中的最后一个子弹发射后回到第一个子弹。
创建一个名为InputController的新类,并输入以下代码:
public class InputController {
private int currentBullet;
Rect left;
Rect right;
Rect thrust;
Rect shoot;
Rect pause;
InputController(int screenWidth, int screenHeight) {
//Configure the player buttons
int buttonWidth = screenWidth / 8;
int buttonHeight = screenHeight / 7;
int buttonPadding = screenWidth / 80;
left = new Rect(buttonPadding,
screenHeight - buttonHeight - buttonPadding,
buttonWidth,
screenHeight - buttonPadding);
right = new Rect(buttonWidth + buttonPadding,
screenHeight - buttonHeight - buttonPadding,
buttonWidth + buttonPadding + buttonWidth,
screenHeight - buttonPadding);
thrust = new Rect(screenWidth - buttonWidth -
buttonPadding,
screenHeight - buttonHeight - buttonPadding -
buttonHeight - buttonPadding,
screenWidth - buttonPadding,
screenHeight - buttonPadding - buttonHeight -
buttonPadding);
shoot = new Rect(screenWidth - buttonWidth -
buttonPadding,
screenHeight - buttonHeight - buttonPadding,
screenWidth - buttonPadding,
screenHeight - buttonPadding);
pause = new Rect(screenWidth - buttonPadding -
buttonWidth,
buttonPadding,
screenWidth - buttonPadding,
buttonPadding + buttonHeight);
让我们将所有按钮捆绑在一个列表中,并通过一个公共方法使它们可用。
}
public ArrayList getButtons(){
//create an array of buttons for the draw method
ArrayList<Rect> currentButtonList = new ArrayList<>();
currentButtonList.add(left);
currentButtonList.add(right);
currentButtonList.add(thrust);
currentButtonList.add(shoot);
currentButtonList.add(pause);
return currentButtonList;
}
接下来,我们像以前一样处理输入,只是调用我们的Ship类的方法。
public void handleInput(MotionEvent motionEvent,GameManager l,
SoundManager sound){
int pointerCount = motionEvent.getPointerCount();
for (int i = 0; i < pointerCount; i++) {
int x = (int) motionEvent.getX(i);
int y = (int) motionEvent.getY(i);
switch (motionEvent.getAction() &
MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
if (right.contains(x, y)) {
l.ship.setPressingRight(true);
l.ship.setPressingLeft(false);
} else if (left.contains(x, y)) {
l.ship.setPressingLeft(true);
l.ship.setPressingRight(false);
} else if (thrust.contains(x, y)) {
l.ship.toggleThrust();
} else if (shoot.contains(x, y)) {
if (l.ship.pullTrigger()) {
l.bullets[currentBullet].shoot
(l.ship.getFacingAngle());
currentBullet++;
// If we are on the last bullet restart
// from the first one again
if(currentBullet == l.numBullets){
currentBullet = 0;
}
sound.playSound("shoot");
}
} else if (pause.contains(x, y)) {
l.switchPlayingStatus();
}
break;
case MotionEvent.ACTION_UP:
if (right.contains(x, y)) {
l.ship.setPressingRight(false);
} else if (left.contains(x, y)) {
l.ship.setPressingLeft(false);
}
break;
case MotionEvent.ACTION_POINTER_DOWN:
if (right.contains(x, y)) {
l.ship.setPressingRight(true);
l.ship.setPressingLeft(false);
} else if (left.contains(x, y)) {
l.ship.setPressingLeft(true);
l.ship.setPressingRight(false);
} else if (thrust.contains(x, y)) {
l.ship.toggleThrust();
} else if (shoot.contains(x, y)) {
if (l.ship.pullTrigger()) {
l.bullets[currentBullet].shoot
(l.ship.getFacingAngle());
currentBullet++;
// If we are on the last bullet restart
// from the first one again
if(currentBullet == l.numBullets){
currentBullet = 0;
}
sound.playSound("shoot");
}
} else if (pause.contains(x, y)) {
l.switchPlayingStatus();
}
break;
case MotionEvent.ACTION_POINTER_UP:
if (right.contains(x, y)) {
l.ship.setPressingRight(false);
} else if (left.contains(x, y)) {
l.ship.setPressingLeft(false);
}
break;
}
}
}
}
现在,我们可以四处飞行并发射几轮太空子弹!当然,在绘制本章后面的 HUD 之前,您将不得不估计屏幕位置。别忘了玩家需要首先点击暂停按钮(右上角)。
注意
请注意,目前我们不使用resetBullet方法,一旦您发射了二十颗子弹,您将无法再射击。我们可以快速检查子弹是否位于边界外,然后调用resetBullet,但我们将与所有的碰撞检测一起,在下一章中完全处理这个问题。
当然,没有行星的话,我们不能有一个行星游戏。
绘制和移动行星
最后,我们将添加酷炫的旋转行星。首先,我们将看看与其他游戏对象构造函数相当相似的构造函数,不同之处在于我们随机设置世界位置。但是,需要特别小心,不要在游戏开始的太空船中心位置生成它们。
创建一个名为Asteroid的新类,并添加这个构造函数。注意我们没有定义任何顶点。我们将这个任务委托给即将看到的generatePoints方法。
public class Asteroid extends GameObject{
PointF[] points;
public Asteroid(int levelNumber, int mapWidth, int mapHeight){
super();
// set a random rotation rate in degrees per second
Random r = new Random();
setRotationRate(r.nextInt(50 * levelNumber) + 10);
// travel at any random angle
setTravellingAngle(r.nextInt(360));
// Spawn asteroids between 50 and 550 on x and y
// And avoid the extreme edges of map
int x = r.nextInt(mapWidth - 100)+50;
int y = r.nextInt(mapHeight - 100)+50;
// Avoid the center where the player spawns
if(x > 250 && x < 350){ x = x + 100;}
if(y > 250 && y < 350){ y = y + 100;}
// Set the location
setWorldLocation(x,y);
// Make them a random speed with the maximum
// being appropriate to the level number
setSpeed(r.nextInt(25 * levelNumber)+1);
setMaxSpeed(140);
// Cap the speed
if (getSpeed() > getMaxSpeed()){
setSpeed(getMaxSpeed());
}
// Make sure we know this object is a ship
setType(Type.ASTEROID);
// Define a random asteroid shape
// Then call the parent setVertices()
generatePoints();
}
我们的更新方法仅根据速度和移动角度计算速度,就像我们对SpaceShip类所做的那样。然后以常规方式调用move()。
public void update(float fps){
setxVelocity ((float) (getSpeed() * Math.cos(Math.toRadians (getTravellingAngle() + 90))));
setyVelocity ((float) (getSpeed() * Math.sin(Math.toRadians(getTravellingAngle() + 90))));
move(fps);
}
在这里我们看到generatePoints方法,它将创建一个随机形状的行星。简单来说,每个行星都有六个顶点。每个顶点都有一个随机生成的位置,但限制相当严格,这样我们就不会得到任何重叠的线条。
// Create a random asteroid shape
public void generatePoints(){
points = new PointF[7];
Random r = new Random();
int i;
// First a point roughly centre below 0
points[0] = new PointF();
i = (r.nextInt(10))+1;
if(i % 2 == 0){i = -i;}
points[0].x = i;
i = -(r.nextInt(20)+5);
points[0].y = i;
// Now a point still below centre but to the right and up a bit
points[1] = new PointF();
i = r.nextInt(14)+11;
points[1].x = i;
i = -(r.nextInt(12)+1);
points[1].y = i;
// Above 0 to the right
points[2] = new PointF();
i = r.nextInt(14)+11;
points[1].x = i;
i = r.nextInt(12)+1;
points[2].y = i;
// A point roughly centre above 0
points[3] = new PointF();
i = (r.nextInt(10))+1;
if(i % 2 == 0){i = -i;}
points[3].x = i;
i = r.nextInt(20)+5;
points[3].y = i;
// left above 0
points[4] = new PointF();
i = -(r.nextInt(14)+11);
points[4].x = i;
i = r.nextInt(12)+1;
points[4].y = i ;
// left below 0
points[5] = new PointF();
i = -(r.nextInt(14)+11);
points[5].x = i;
i = -(r.nextInt(12)+1);
points[5].y = i;
现在,我们有六个点用来构建表示顶点的浮点数数组。最后,我们调用setVertices()来创建我们的ByteBuffer。请注意,行星将被绘制成一系列的线条,这就是数组中的最后一个顶点与第一个顶点相同的原因。
// Now use these points to draw our asteroid
float[] asteroidVertices = new float[]{
// First point to second point
points[0].x, points[0].y, 0,
points[1].x, points[1].y, 0,
// 2nd to 3rd
points[1].x, points[1].y, 0,
points[2].x, points[2].y, 0,
// 3 to 4
points[2].x, points[2].y, 0,
points[3].x, points[3].y, 0,
// 4 to 5
points[3].x, points[3].y, 0,
points[4].x, points[4].y, 0,
// 5 to 6
points[4].x, points[4].y, 0,
points[5].x, points[5].y, 0,
// 6 back to 1
points[5].x, points[5].y, 0,
points[0].x, points[0].y, 0,
};
setVertices(asteroidVertices);
}// End method
}// End class
如您所料,我们在GameManager中添加了一个数组来保存所有的行星。同时,我们还将声明一些变量,用来记录玩家当前的关卡以及初始(基础)的行星数量。随后,当我们初始化所有行星时,我们将看到如何确定需要摧毁的行星数量以完成一个关卡。
Asteroid [] asteroids;
int numAsteroids;
int numAsteroidsRemaining;
int baseNumAsteroids = 10;
int levelNumber = 1;
在GameManager构造函数中初始化数组:
// For all our asteroids
asteroids = new Asteroid[500];
在createObjects方法中使用我们之前声明的变量来初始化对象本身,根据当前关卡确定行星的数量。
// Determine the number of asteroids
gm.numAsteroids = gm.baseNumAsteroids * gm.levelNumber;
// Set how many asteroids need to be destroyed by player
gm.numAsteroidsRemaining = gm.numAsteroids;
// Spawn the asteroids
for (int i = 0; i < gm.numAsteroids * gm.levelNumber; i++) {
// Create a new asteroid
// Pass in level number so they can be made
// appropriately dangerous.
gm.asteroids[i] = new Asteroid
(gm.levelNumber, gm.mapWidth, gm.mapHeight);
}
在update方法中更新它们。
// Update all the asteroids
for (int i = 0; i < gm.numAsteroids; i++) {
if (gm.asteroids[i].isActive()) {
gm.asteroids[i].update(fps);
}
}
最后,我们在draw方法中绘制所有的行星。
// The bullets
for (int i = 0; i < gm.numBullets; i++) {
gm.bullets[i].draw(viewportMatrix);
}
for (int i = 0; i < gm.numAsteroids; i++) {
if (gm.asteroids[i].isActive()) {
gm.asteroids[i].draw(viewportMatrix);
}
}
现在,运行游戏并查看那些流畅的 60+ FPS 旋转行星。
现在,我们需要通过添加按钮图像以及一些其他覆盖信息,包括 HUD,来使控制飞船变得容易。
分数和 HUD(头上显示装置)
HUD 对象永远不会被旋转。另外,它们是在InputController类中根据屏幕坐标定义的,而不是游戏世界或甚至是 OpenGL 坐标。因此,我们的GameObject类不是一个合适的父类。
为了简单起见,这三个 HUD 类将各自拥有自己的draw方法。我们将看到如何使用新的视口矩阵以一致的大小和屏幕位置绘制它们。
创建了我们所有的 HUD 类之后,我们将添加所有的对象声明、初始化和绘制代码。
添加控制按钮
我们将为第一个 HUD 对象创建一个类,这是一个简单的按钮。
注意
我明确地展示了所有的导入语句,因为它们不会自动导入。请注意,接下来的两个类也需要这些。代码像往常一样包含在下载包中,如果你希望直接复制粘贴。
创建一个新类,将其命名为GameButton,然后添加以下导入语句。请确保根据你使用的章节代码或你给项目命的名声明正确的包名。
import android.graphics.PointF;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import static android.opengl.GLES20.GL_FLOAT;
import static android.opengl.GLES20.GL_LINES;
import static android.opengl.GLES20.glDrawArrays;
import static android.opengl.GLES20.glEnableVertexAttribArray;
import static android.opengl.GLES20.glGetAttribLocation;
import static android.opengl.GLES20.glGetUniformLocation;
import static android.opengl.GLES20.glUniform4f;
import static android.opengl.GLES20.glUniformMatrix4fv;
import static android.opengl.GLES20.glUseProgram;
import static android.opengl.Matrix.orthoM;
import static android.opengl.GLES20.glVertexAttribPointer;
import static com.gamecodeschool.c10asteroids.GLManager.A_POSITION;
import static com.gamecodeschool.c10asteroids.GLManager.COMPONENTS_PER_VERTEX;
import static com.gamecodeschool.c10asteroids.GLManager.FLOAT_SIZE;
import static com.gamecodeschool.c10asteroids.GLManager.STRIDE;
import static com.gamecodeschool.c10asteroids.GLManager.U_COLOR;
import static com.gamecodeschool.c10asteroids.GLManager.U_MATRIX;
首先,我们声明一些成员;viewportMatrix,我们将把来自InputController类的基于屏幕坐标的视口变换的新矩阵放入其中——一个整型glprogram值,一个int numVertices值,以及一个FloatBuffer类。
public class GameButton {
// For button coordinate
// into a GL space coordinate (-1,-1 to 1,1)
// for drawing on the screen
private final float[] viewportMatrix = new float[16];
// A handle to the GL glProgram -
// the compiled and linked shaders
private static int glProgram;
// How many vertices does it take to make
// our button
private int numVertices;
// This will hold our vertex data that is
// passed into openGL glProgram
private FloatBuffer vertices;
在构造函数中我们首先通过调用orthoM()并传入屏幕的高度和宽度作为0,0来创建我们的视口矩阵。这使得 OpenGL 将一个与设备分辨率相同的坐标范围映射到 OpenGL 坐标范围之上。
然后,我们获取传入按钮的坐标并将其缩小以使其变小。然后,我们初始化一个顶点数组作为四条线来表示一个按钮。显然,我们将需要创建一个新的按钮对象来代表InputController类中的每个按钮。
public GameButton(int top, int left,
int bottom, int right, GameManager gm){
//The HUD needs its own viewport
// notice we set the screen height in pixels as the
// starting y coordinates because
// OpenGL is upside down world :-)
orthoM(viewportMatrix, 0, 0,
gm.screenWidth, gm.screenHeight, 0, 0, 1f);
// Shrink the button visuals to make
// them less obtrusive while leaving
// the screen area they represent the same.
int width = (right - left) / 2;
int height = (top - bottom) / 2;
left = left + width / 2;
right = right - width / 2;
top = top - height / 2;
bottom = bottom + height / 2;
PointF p1 = new PointF();
p1.x = left;
p1.y = top;
PointF p2 = new PointF();
p2.x = right;
p2.y = top;
PointF p3 = new PointF();
p3.x = right;
p3.y = bottom;
PointF p4 = new PointF();
p4.x = left;
p4.y = bottom;
// Add the four points to an array of vertices
// This time, because we don't need to animate the border
// we can just declare the world space coordinates, the
// same as above.
float[] modelVertices = new float[]{
// A line from point 1 to point 2
p1.x, p1.y, 0,
p2.x, p2.y, 0,
// Point 2 to point 3
p2.x, p2.y, 0,
p3.x, p3.y, 0,
// Point 3 to point 4
p3.x, p3.y, 0,
p4.x, p4.y, 0,
// Point 4 to point 1
p4.x, p4.y, 0,
p1.x, p1.y, 0
};
现在,我们从GameObject复制了一些代码来准备ByteBuffer,但我们仍然使用我们的静态GLManager.getGLProgram()来获取 GL 程序的句柄。
// Store how many vertices and
// elements there is for future use
final int ELEMENTS_PER_VERTEX = 3;// x,y,z
int numElements = modelVertices.length;
numVertices = numElements/ELEMENTS_PER_VERTEX;
// Initialize the vertices ByteBuffer object based on the
// number of vertices in the button and the number of
// bytes there are in the float type
vertices = ByteBuffer.allocateDirect(
numElements
* FLOAT_SIZE)
.order(ByteOrder.nativeOrder()).asFloatBuffer();
// Add the button into the ByteBuffer object
vertices.put(modelVertices);
glProgram = GLManager.getGLProgram();
}
最后,我们实现了draw方法,这是来自GameObject的draw方法的简化版本。注意我们不需要处理模型、转换和旋转矩阵,并且我们传递了一个不同的颜色给片段着色器。
public void draw(){
// And tell OpenGl to use the glProgram
glUseProgram(glProgram);
// Now we have a glProgram we need the locations
// of our three GLSL variables
int uMatrixLocation = glGetUniformLocation(glProgram, U_MATRIX);
int aPositionLocation =
glGetAttribLocation(glProgram, A_POSITION);
int uColorLocation = glGetUniformLocation(glProgram, U_COLOR);
vertices.position(0);
glVertexAttribPointer(
aPositionLocation,
COMPONENTS_PER_VERTEX,
GL_FLOAT,
false,
STRIDE,
vertices);
glEnableVertexAttribArray(aPositionLocation);
// give the new matrix to OpenGL
glUniformMatrix4fv(uMatrixLocation, 1, false, viewportMatrix, 0);
// Assign a different color to the fragment shader
glUniform4f(uColorLocation, 0.0f, 0.0f, 1.0f, 1.0f);
// Draw the lines
// start at the first element of the
// vertices array and read in all vertices
glDrawArrays(GL_LINES, 0, numVertices);
}
}// End class
计数字符
这个类与GameButton相同,不同之处在于计数字符将是一个单一的垂直直线;因此,我们只需要两个顶点。
但是请注意,我们在构造函数中有一个名为nthIcon的参数。调用代码需要负责让TallyIcon知道已经创建的TallyIcon对象的总数量加一。然后,当前的TallyIcon对象可以使用内边距变量来适当定位自己。
创建一个名为 TallyIcon 的新类,并输入以下代码。像之前一样,根据需要包含静态导入。以下是所有声明和构造函数的代码:
public class TallyIcon {
// For button coordinate
// into a GL space coordinate (-1,-1 to 1,1)
// for drawing on the screen
private final float[] viewportMatrix = new float[16];
// A handle to the GL glProgram -
// the compiled and linked shaders
private static int glProgram;
// How many vertices does it take to make
// our button
private int numVertices;
// This will hold our vertex data that is
// passed into openGL glProgram
//private final FloatBuffer vertices;
private FloatBuffer vertices;
public TallyIcon(GameManager gm, int nthIcon){
// The HUD needs its own viewport
// notice we set the screen height in pixels as the
// starting y coordinates because
// OpenGL is upside down world :-)
orthoM(viewportMatrix, 0, 0,
gm.screenWidth, gm.screenHeight, 0, 0f, 1f);
float padding = gm.screenWidth / 160;
float iconHeight = gm.screenHeight / 15;
float iconWidth = 1; // square icons
float startX = 10 + (padding + iconWidth)* nthIcon;
float startY = iconHeight * 2 + padding;
PointF p1 = new PointF();
p1.x = startX;
p1.y = startY;
PointF p2 = new PointF();
p2.x = startX;
p2.y = startY - iconHeight;
// Add the four points to an array of vertices
// This time, because we don't need to animate the border
// we can just declare the world space coordinates, the
// same as above.
float[] modelVertices = new float[]{
// A line from point 1 to point 2
p1.x, p1.y, 0,
p2.x, p2.y, 0,
};
// Store how many vertices and
//elements there is for future use
final int ELEMENTS_PER_VERTEX = 3;// x,y,z
int numElements = modelVertices.length;
numVertices = numElements/ELEMENTS_PER_VERTEX;
// Initialize the vertices ByteBuffer object based on the
// number of vertices in the button and the number of
// bytes there are in the float type
vertices = ByteBuffer.allocateDirect(
numElements
* FLOAT_SIZE)
.order(ByteOrder.nativeOrder()).asFloatBuffer();
// Add the button into the ByteBuffer object
vertices.put(modelVertices);
glProgram = GLManager.getGLProgram();
}
这就是 draw 方法,现在看起来可能相当熟悉了。
public void draw(){
// And tell OpenGl to use the glProgram
glUseProgram(glProgram);
// Now we have a glProgram we need the locations
// of our three GLSL variables
int uMatrixLocation =
glGetUniformLocation(glProgram, U_MATRIX);
int aPositionLocation =
glGetAttribLocation(glProgram, A_POSITION);
int uColorLocation =
glGetUniformLocation(glProgram, U_COLOR);
vertices.position(0);
glVertexAttribPointer(
aPositionLocation,
COMPONENTS_PER_VERTEX,
GL_FLOAT,
false,
STRIDE,
vertices);
glEnableVertexAttribArray(aPositionLocation);
// Just give the passed in matrix to OpenGL
glUniformMatrix4fv(uMatrixLocation, 1,
false, viewportMatrix, 0);
// Assign a color to the fragment shader
glUniform4f(uColorLocation, 1.0f, 1.0f, 0.0f, 1.0f);
// Draw the lines
// start at the first element of the vertices array and read in all vertices
glDrawArrays(GL_LINES, 0, numVertices);
}
现在是最后的 HUD 元素。
生命图标
我们最后的图标将是一种迷你飞船,用来指示玩家还剩下多少生命。
我们将使用线条构建一个三角形形状,以创建一个漂亮的空心效果。请注意,LifeIcon 构造函数还使用 nthIcon 元素来控制填充和屏幕上的位置。
创建一个名为 LifeIcon 的新类,并输入以下代码,记住所有不会自动导入的导入语句。以下是声明和构造函数:
public class LifeIcon {
// Remember the static import for GLManager
// For button coordinate
// into a GL space coordinate (-1,-1 to 1,1)
// for drawing on the screen
private final float[] viewportMatrix = new float[16];
// A handle to the GL glProgram -
// the compiled and linked shaders
private static int glProgram;
// Each of the above constants also has a matching int
// which will represent its location in the open GL glProgram
// In GameButton they are declared as local variables
// How many vertices does it take to make
// our button
private int numVertices;
// This will hold our vertex data that is
// passed into openGL glProgram
//private final FloatBuffer vertices;
private FloatBuffer vertices;
public LifeIcon(GameManager gm, int nthIcon){
// The HUD needs its own viewport
// notice we set the screen height in pixels as the
// starting y coordinates because
// OpenGL is upside down world :-)
orthoM(viewportMatrix, 0, 0,
gm.screenWidth, gm.screenHeight, 0, 0f, 1f);
float padding = gm.screenWidth / 160;
float iconHeight = gm.screenHeight / 15;
float iconWidth = gm.screenWidth / 30;
float startX = 10 + (padding + iconWidth)* nthIcon;
float startY = iconHeight;
PointF p1 = new PointF();
p1.x = startX;
p1.y = startY;
PointF p2 = new PointF();
p2.x = startX + iconWidth;
p2.y = startY;
PointF p3 = new PointF();
p3.x = startX + iconWidth/2;
p3.y = startY - iconHeight;
// Add the four points to an array of vertices
// This time, because we don't need to animate the border
// we can just declare the world space coordinates, the
// same as above.
float[] modelVertices = new float[]{
// A line from point 1 to point 2
p1.x, p1.y, 0,
p2.x, p2.y, 0,
// Point 2 to point 3
p2.x, p2.y, 0,
p3.x, p3.y, 0,
// Point 3 to point 1
p3.x, p3.y, 0,
p1.x, p1.y, 0,
};
// Store how many vertices and elements there is for future
// use
final int ELEMENTS_PER_VERTEX = 3;// x,y,z
int numElements = modelVertices.length;
numVertices = numElements/ELEMENTS_PER_VERTEX;
// Initialize the vertices ByteBuffer object based on the
// number of vertices in the button and the number of
// bytes there are in the float type
vertices = ByteBuffer.allocateDirect(
numElements
* FLOAT_SIZE)
.order(ByteOrder.nativeOrder()).asFloatBuffer();
// Add the button into the ByteBuffer object
vertices.put(modelVertices);
glProgram = GLManager.getGLProgram();
}
这是 LifeIcon 类的 draw 方法:
public void draw(){
// And tell OpenGl to use the glProgram
glUseProgram(glProgram);
// Now we have a glProgram we need the locations
// of our three GLSL variables
int uMatrixLocation = glGetUniformLocation
(glProgram, U_MATRIX);
int aPositionLocation = glGetAttribLocation
(glProgram, A_POSITION);
int uColorLocation = glGetUniformLocation
(glProgram, U_COLOR);
vertices.position(0);
glVertexAttribPointer(
aPositionLocation,
COMPONENTS_PER_VERTEX,
GL_FLOAT,
false,
STRIDE,
vertices);
glEnableVertexAttribArray(aPositionLocation);
// Just give the passed in matrix to OpenGL
glUniformMatrix4fv(uMatrixLocation, 1,
false, viewportMatrix, 0);
// Assign a color to the fragment shader
glUniform4f(uColorLocation, 1.0f,
1.0f, 0.0f, 1.0f);
// Draw the lines
// start at the first element of
// the vertices array and read in all vertices
glDrawArrays(GL_LINES, 0, numVertices);
}
}
我们已经有了三个 HUD 类,并且可以将它们绘制到屏幕上。
声明、初始化并绘制 HUD 对象
我们将像所有 GameObject 类一样声明、初始化并绘制我们的 HUD 对象。但是请注意,如预期的那样,我们不向 draw 方法传递视口矩阵,因为 HUD 类提供了自己的视口矩阵。
向 GameManager 添加这些成员:
TallyIcon[] tallyIcons;
int numLives = 3;
LifeIcon[] lifeIcons;
与我们对 asteroids 数组的操作一样,在 GameManager 构造函数中初始化 tallyIcons 和 lifeIcons:
lifeIcons = new LifeIcon[50];
tallyIcons = new TallyIcon[500];
向 AsteroidsRenderer 类添加一个新的成员数组:
// This will hold our game buttons
private final GameButton[] gameButtons = new GameButton[5];
添加这段代码以创建我们所有新 HUD 类的对象。将其添加到 createObjects 方法中的闭合大括号之前:
// Now for the HUD objects
// First the life icons
for(int i = 0; i < gm.numLives; i++) {
// Notice we send in which icon this represents
// from left to right so padding and positioning is correct.
gm.lifeIcons[i] = new LifeIcon(gm, i);
}
// Now the tally icons (1 at the start)
for(int i = 0; i < gm.numAsteroidsRemaining; i++) {
// Notice we send in which icon this represents
// from left to right so padding and positioning is correct.
gm.tallyIcons[i] = new TallyIcon(gm, i);
}
// Now the buttons
ArrayList<Rect> buttonsToDraw = ic.getButtons();
int i = 0;
for (Rect rect : buttonsToDraw) {
gameButtons[i] = new GameButton(rect.top, rect.left,
rect.bottom, rect.right, gm);
i++;
}
现在,我们可以根据剩余的生命次数和升级前剩余的 asteroids 数量来绘制我们的 HUD。将此代码添加到 draw 方法的末尾:
// the buttons
for (int i = 0; i < gameButtons.length; i++) {
gameButtons[i].draw();
}
// Draw the life icons
for(int i = 0; i < gm.numLives; i++) {
// Notice we send in which icon this represents
// from left to right so padding and positioning is correct.
gm.lifeIcons[i].draw();
}
// Draw the level icons
for(int i = 0; i < gm.numAsteroidsRemaining; i++) {
// Notice we send in which icon this represents
// from left to right so padding and positioning is correct.
gm.tallyIcons[i].draw();
}
现在你可以飞来飞去,欣赏你的新 HUD 了。
显然,如果我们想要充分利用生命和 asteroid 计数指示器,那么我们首先需要能够射击 asteroid,并在飞船被击中时检测到它们。
总结
在本章中我们取得了很大的成就,实际上可以很容易地快速添加更多的游戏对象。也许,可以像原始街机经典游戏中那样偶尔添加一个 UFO。
在下一章中,我们将利用在前一个项目中学习到的内容来设置碰撞检测,并完成游戏。然而,一个拥有精确、清晰、平滑移动线条的游戏,理应比我们至今所使用的更精确的碰撞检测。
因此,我们将专注于实现精确高效的碰撞检测,以使我们的 Asteroids 仿真模拟器得以完善。