这是本书最长的章节之一,在我们的设备/仿真器上看到结果之前,我们有相当多的工作和理论要完成。然而,你将学到的东西,然后实施,会给你能力,大幅增加游戏的复杂性,你可以建立。一旦你理解了什么是实体-组件系统,以及如何使用工厂模式构建游戏对象,你将能够将几乎任何你能想象的游戏对象添加到你的游戏中。如果你买这本书不仅仅是为了学习 Java,而是因为你想设计和开发你自己的电子游戏,那么这一章就属于你了。
在本章中,我们将涵盖以下主题:
- 更仔细地观察游戏对象及其多样性导致的问题
- 引入实体-组件模式
- 介绍简单工厂模式
- 每个物体都是一个理论
- 指定所有游戏对象
- 编码接口以匹配所有需要的组件
- 准备一些空的组件类
- 编写通用
Transform
类 - 每个对象都是一个
GameObject
——实现 - 对播放器的组件进行编码
- 对激光器组件进行编码
- 编码背景的组成部分
- 建一个
GameObjectFactory
班 - 编码
Level
类 - 把这一章的所有内容放在一起
这是一个相当大的清单,所以我们最好开始。
因为我们将在本章开始游戏对象,让我们添加所有的图形文件到项目。图形文件可以从 GitHub repo 上的Chapter 20/drawable
文件夹中获取。直接复制粘贴到 AndroidStudio 项目浏览器窗口的app/res/drawable
文件夹中。
这是一个重要的话题,当我们接下来更详细地讨论设计模式时,它将为我们做好准备。快速查看以下图形,所有图形都代表游戏对象,以便我们充分了解我们将使用的内容:
图 20.1–游戏对象的表示
现在,我们可以了解实体-组件模式。
我们现在将花 5 分钟时间沉浸在显然无法解决的混乱的痛苦中。然后,我们将看到实体-组件模式是如何拯救的。
这个项目设计提出了多个需要讨论的问题,然后我们才能开始敲击键盘。首先是游戏对象的多样性。让我们考虑如何处理所有不同的对象。
在前面的项目中,我们为每个对象编写了一个类。我们有像Bat
、Ball
、Snake
和Apple
这样的课程。然后,在update
方法中,我们会更新它们,而在draw
方法中,我们会绘制它们。在最近的项目 Snake 中,我们朝着正确的方向迈出了一步,并在更新和绘制阶段让每个对象处理自己。
我们可以开始使用相同的结构来完成这个项目。这是可行的,但是在项目接近尾声时,一些主要的编码噩梦会变得很明显。
在前面的项目中,一旦我们对对象进行了编码,我们所需要做的就是在游戏开始时实例化它们,也许是这样的:
Snake mSnake = new Snake(Whatever parameters);
Apple mApple = new Apple(Whatever parameters);
然后,我们会更新它们,可能是这样的:
mSnake.update(mFPS);
// Apple doesn't need updating
像这样画它们:
mSnake.draw(mPaint, mCanvas);
mApple.draw(mPaint, mCanvas);
看起来我们这次需要做的只是编码、实例化、更新和绘制一堆不同的对象,可能是这样的:
Diver mDiver = new Diver(Whatever parameters);
Chaser mChaser = new Chaser(Whatever parameters);
Patroller mPatroller = new Patroller(Whatever parameters);
PlayerShip mPlayerShip = new PlayerShip(Whatever parameters);
然后,我们需要更新它们:
mDiver.update(mFPS);
mChaser.update(mFPS);
mPatroller.update(mFPS);
mPlayerShip.update(mFPS);
之后,我们需要画出它们:
mDiver.draw(mPaint, mCanvas);
mChaser.draw(mPaint, mCanvas);
mPatroller.draw(mPaint, mCanvas);
mPlayerShip.draw(mPaint, mCanvas);
但是当我们需要,比如说,三个追逐者的时候,我们该怎么办?下面是对象初始化部分:
Diver mDiver = new Diver(Whatever parameters);
Chaser mChaser1 = new Chaser(Whatever parameters);
Chaser mChaser2 = new Chaser(Whatever parameters);
Chaser mChaser3 = new Chaser(Whatever parameters);
Patroller mPatroller = new Patroller(Whatever parameters);
PlayerShip mPlayerShip = new PlayerShip(Whatever parameters);
它会起作用,但是接下来update
和draw
方法也必须增长。现在,考虑碰撞检测。我们需要分别获得每个外星人的碰撞器和每个激光器,然后在玩家面前测试它们。然后,有所有玩家的激光对付所有外星人。它已经很笨重了。如果我们有 10 个甚至 20 个游戏对象呢?游戏引擎会失控,变成编程噩梦。
这种方法的另一个问题是我们不能利用继承。例如,所有的外星人、激光和玩家都以几乎相同的方式绘制自己。我们最终将得到大约六个具有相同代码的draw
方法。如果我们改变调用draw
的方式或者处理位图的方式,我们将需要更新所有六个类。
肯定有更好的办法。
如果每一个物体、玩家、所有外星人类型和所有激光都是一个通用类型,那么我们可以将它们打包成ArrayList
实例或类似的东西,并循环通过它们的每一个update
方法,然后是它们的每一个draw
方法。
我们已经知道了一种方法——继承。乍一看,这似乎是一个完美的解决方案。我们可以创建一个抽象的GameObject
类,然后用Player
、Laser
、Diver
、Chaser
和Patroller
类扩展它
在六个类中相同的draw
方法可以保留在父类中,这样我们就不会有浪费、难以维护、重复代码的问题。太好了。
这种方法的问题在于游戏对象在某些方面有多多样。例如,所有的外星人移动方式都不同。追逐者追逐玩家,而巡逻者只是从左到右,从右到左的飞来飞去。潜水员不断地从顶部呼吸和潜水。
我们如何将这种多样性融入到需要控制这种运动的update
方法中?也许我们可以这样做:
void update(long fps){
switch(alienType){
case "chaser":
// All the chaser's logic goes here
Break;
case "patroller":
// All the patroller's logic here
Break;
case "diver":
// All the diver's logic here
Break;
}
}
光是update
方法就比整个GameEngine
类都大,我们甚至还没有考虑如何处理玩家和激光。
正如大家可能从 第八章面向对象编程中回忆到的,我们扩展一个类的时候,也可以覆盖具体的方法。这意味着我们可以为每一种外星人类型准备一个不同版本的update
。不幸的是,这种方法也有一个问题。
GameEngine
类引擎必须“知道”它正在更新的是哪种类型的对象,或者至少能够查询它正在更新的GameObject
类,以便调用正确的update
方法,可能是这样的:
if(currentObject.getType == "diver"){
// Get the diver element from the GameObject
diver temporaryDiver = (Diver)currentObject;
// Now we can call update- at last
temporaryDiver.update(fps);
// Now handle every other type of GameObject sub-class
}
即使是解决方案中看起来有效的部分,在仔细观察后也会分崩离析。我之前提到过draw
方法中的代码对于六个对象是相同的,所以draw
方法可以是父类的一部分,并且被所有子类使用,而不是我们必须编码六个单独的绘制方法。
那么,当可能只有一个对象需要以不同的方式绘制时,比如滚动背景,会发生什么呢?答案是解决方案不起作用。
现在我们已经看到了当对象彼此不同,但却大声呼喊来自同一个父类时出现的问题,是时候看看我们将在这个项目和下一个项目中使用的解决方案了。
我们需要的是一种新的思维方式来构建我们所有的游戏对象。
继承之上的组合是指在其他对象中组合对象的思想。如果我们可以编写一个类(而不是一个方法)来处理对象是如何绘制的呢?然后,对于所有以相同方式绘制自己的类,我们可以在GameObject
内实例化其中一个特殊的绘制类。然后,当GameObject
做了不同的事情时,我们可以简单地用不同的绘图、更新或任何相关的类来组合它。我们所有对象中的所有相似之处都可以从使用相同的代码中受益,所有的不同之处不仅可以从封装中受益,还可以从基类中抽象出来。
注意这一节的标题是构图战胜继承,不是构图代替继承。组合并不能取代继承,你在 第八章面向对象编程中所学的一切,依然适用,但是在适当的时候,组合而不是继承。
GameObject
类是实体,它将由做诸如更新其位置并将其绘制到屏幕上的事情的类组成,这些类是它的组件,因此是实体-组件模式。
当我们使用组合而不是继承来创建一组类来表示我们这里的行为/算法时,这也被称为策略模式。你可以愉快地使用你在这里学到的一切,并将其称为战略模式。实体组件是一个不太为人所知但更具体的实现,这就是为什么我们称之为。区别在于学术性,但如果你想进一步探索事物,请随时求助于谷歌。
然而,我们自己面临的问题是,知道任何给定的对象将由什么组成以及知道如何将它们组合在一起本身就有点技术性。
我的意思是,这几乎就像我们需要一个工厂来完成我们所有的物体组装。
实体-组件模式,以及优先于继承的组合使用,起初听起来很棒,但也带来了一些问题,尤其是它加剧了我们已经讨论过的所有问题。这意味着我们新的GameObject
类需要“知道”游戏中所有不同的组件和每一种类型的对象。它将如何向自身添加所有正确的组件?
的确,如果我们要拥有这个通用的GameObject
类,它可以是我们想要的任何东西,无论是Diver
、Patroller
、PlayerShip
、PinkElephant
还是其他什么东西,那么我们将不得不编码一些逻辑,这些逻辑“知道”如何构建这些超级灵活的GameObject
实例,并用正确的组件组成它们。但是,将所有这些代码添加到类本身会使它异常笨拙,并首先否定使用实体组件模式的全部理由。
我们需要一个构造函数来完成类似于这个假设的GameObject
代码所做的事情:
class GameObject{
MovementComponent mMoveComp;
GraphicsComponent mGraphComp;
InputComponent mInpComp;
Transform mTransform;
// More members here
// The constructor
GameObject(String type){
if(type == "diver"){
mMoveComp = new DiverMovementComponent ();
mGraphComp = new StdGraphicsComponent();
}
else if(type =="chaser"){
mMoveComp = new ChaserMovementComponent();
mGraphComp = new StdGraphicsComponent();
}
// etc.
…
}
}
别忘了我们需要对每种类型的外星人、背景和每个组件的玩家做同样的事情。GameObject
类不仅需要知道哪些组件与哪些GameObject
一起使用,还需要知道哪些GameObject
实例不需要某些组件,例如用于控制玩家的输入相关组件GameObject
。
比如在 第 19 章用观察者模式收听、多点触控、构建粒子系统中,我们编码了一个UIController
类,注册了GameEngine
类作为观察者。每次GameEngine
在onTouchEvent
方法中接收到触摸数据时,我们通过将Rect
对象的ArrayList
传递到其handleInput
方法,使其知道抬头显示器按钮。
GameObject
类将需要理解所有这些逻辑。在实体-组件模式中使用组合而不是继承所获得的任何好处或效率都将完全丧失。
此外,如果游戏设计者突然宣布了一种新的外星人——可能是一个传送到玩家附近的隐形人外星人,拍了一张照片,然后又传送走了呢?编码一个新的GraphicsComponent
,也许是一个CloakingGraphicsComponent
,当它可见和不可见的时候“知道”,还有一个新的MovementComponent
,也许是一个CloakerMovementComponent
,它传送而不是常规移动,这没问题,但是我们真的要给GameObject
构造器添加一大堆新的if
语句吗?是的,是这种情况下不幸的答案。
事实上,情况甚至比这更糟。如果游戏设计师在某天早上宣布潜水员现在可以穿斗篷了,会发生什么?潜水员现在不仅仅需要一种不同类型的GraphicsComponent
。回到GameObject
类,我们需要编辑所有那些if
语句。然后,游戏设计师意识到,虽然隐形潜水员很酷,但原来总是可见的潜水员更具威胁性。我们需要潜水员和隐形潜水员,所以需要更多的改变。
如果你想要一个接受输入的新的GameObject
,事情会变得更糟,因为实例化GameObject
的类必须确保传入一个GameEngineBroadcaster
引用,并且GameObject
必须知道如何处理它。
另外,请注意每个GameObject
都有一个InputComponent
,但不是每个GameObject
都需要一个。这是对内存的浪费,意味着要么在不需要的时候初始化InputComponent
,浪费来自GameEngineBroadcaster
的呼叫,要么几乎每个GameObject
都有一个空InputComponent
,随时等待游戏崩溃。
在前面的假设代码中,最后要注意的是Transform
类型的对象。所有GameObject
实例都将由一个Transform
对象组成,该对象保存诸如大小、位置等细节。随着本章的继续,我们将提供更多关于Transform
课程的详细信息。
其实可以想象的场景更多,最终都是越来越大的GameObject
类。工厂模式——或者更准确地说,在这个项目中,简单工厂模式——是这些问题的解决方案,也是实体组件模式的完美合作伙伴。
简单的工厂模式只是开始学习工厂模式的一种更简单的方式。完成这本书后,考虑在网上搜索工厂模式。
游戏设计者将为游戏中的每种类型的对象提供一个规范,程序员将提供一个工厂类,根据游戏设计者的规范构建GameObject
实例。当游戏设计者为实体提出古怪的想法时,我们所需要做的就是要求一个新的规范。有时,这将涉及在使用现有组件的工厂中增加一条新的生产线,尽管有时,这将意味着编码新的组件或者更新现有组件。关键是游戏设计师有多有创造力并不重要;GameObject
、GameEngine
、Renderer
、PhysicsEngine
保持不变。也许我们有这样的东西:
GameObject currentObject = new GameObject;
switch (objectType) {
case "diver":
currentObject.setMovement (new DiverMovement());
currentObject.setDrawing (new StdDrawing());
break;
case "chaser":
currentObject.setMovement (new ChaserMovement());
currentObject.setDrawing (new StdDrawing());
break;
}
这里,检查当前对象类型,并向其添加适当的组件(类)。追逐者和潜水员都有一个StdDrawing
组件,但是两者都有不同的移动(更新)组件。setMovement
和setDrawing
方法是GameObject
类的一部分,我们将在本章的后面部分看到它们的真实等价物。这段代码与我们将要使用的代码并不完全相同,但它离我们并不太远。
这是真的该代码非常类似于我们刚刚讨论并揭示为完全不充分的代码。然而,最大的区别是这段代码只能存在于工厂类的一个实例中,而不能存在于GameObject
的每个实例中。此外,这个类甚至不需要在我们游戏的阶段之后持续存在,当GameObject
实例被建立,准备行动。
我们还将通过编写一个Level
类来进一步研究,该类将决定这些规范的类型和数量。这进一步分离了游戏设计、特定级别设计和游戏引擎/工厂编码的角色和职责。
看看这些要点,它们描述了我们到目前为止讨论的所有内容。
- 我们将有
MovementComponent
、GraphicsComponent
、SpawnComponent
和InputComponent
等组件类。这些将是没有特定功能的接口。 - 会有具体实现这些接口的类,比如
DiverMovement
、PlayerMovement
、StandardGraphics
、BackgroundGraphics
、PlayerInput
等等。 - 我们将为每个游戏对象设置规范类,指定游戏中每个对象将拥有的组件。这些规格还将有额外的细节,如尺寸、速度、名称和所需外观所需的图形文件。
- 将会有一个工厂类知道如何读取规范类并组装通用但内部不同的
GameObject
实例。 - 将会有一个等级类知道每种类型的
GameObject
需要哪种和多少种,并将从工厂类“订购”它们。 - 最终结果将是我们将有一个整洁的
GameObject
实例ArrayList
,非常容易更新、绘制并传递给需要它们的类。
现在,让我们看看我们的对象规范。
现在,我们知道我们所有的游戏对象都将由精选的组件构建而成。有时,这些组件对于特定的游戏对象来说会是唯一的,但大多数情况下,这些组件会用于多个不同的游戏对象中。我们需要一种方法来指定一个游戏对象,以便工厂类知道使用什么组件来构造每个对象。
首先,我们需要一个父规范类,其他规范可以从中派生出来。这允许我们以多种形式使用它们,而不必为每种类型的对象在同一个工厂中构建不同的工厂或不同的方法。
这个类将是所有规范类的基类/父类。它将拥有所有必需的获取器,这样工厂类就可以获得它需要的所有数据。然后,正如我们将很快看到的,所有表示真实游戏对象的类将只需初始化适当的成员变量并调用父类的构造函数。因为我们永远不想实例化这个父类的一个实例,只需要extend
它,我们将将其声明为abstract
。
创建一个名为ObjectSpec
的新类,并按如下方式编码:
import android.graphics.PointF;
abstract class ObjectSpec {
private String mTag;
private String mBitmapName;
private float mSpeed;
private PointF mSizeScale;
private String[] mComponents;
ObjectSpec(String tag, String bitmapName,
float speed, PointF relativeScale,
String[] components) {
mTag = tag;
mBitmapName = bitmapName;
mSpeed = speed;
mSizeScale = relativeScale;
mComponents = components;
}
String getTag() {
return mTag;
}
String getBitmapName() {
return mBitmapName;
}
float getSpeed() {
return mSpeed;
}
PointF getScale() {
return mSizeScale;
}
String[] getComponents() {
return mComponents;
}
}
记下所有成员变量的。每个规格都有一个标签/标识符(mTag
),工厂会将该标签/标识符传递给已完成的GameObject
实例,以便PhysicsEngine
可以做出碰撞决策。每个都有一个位图(mBitmapName
)的名称,它对应于我们添加到drawable
文件夹中的一个图形文件。此外,每个规格将有一个速度和尺寸(mSpeed
和mSizeScale
)。
我们不使用简单的大小变量来代替听起来有点复杂的mSizeScale
变量的原因与使用屏幕坐标而不是世界坐标的问题有关。因此,我们可以缩放所有游戏对象,使其在不同设备上看起来大致相同。我们将使用相对于屏幕上像素数量的尺寸,因此mSizeScale
。在下一个项目中,当我们学习如何实现一个在游戏世界中移动的虚拟相机时,我们的尺寸将更加自然。你可以把尺寸想象成米或者游戏单位。
可能最需要注意的成员变量是字符串的mComponents
数组列表。这将包含构建这个游戏对象所需的所有组件的列表。
如果你回头看构造函数,你会看到它有一个匹配每个成员的参数。然后,在构造函数内部,从参数中初始化每个成员。正如我们在编写真正的规范时所看到的,我们所需要做的就是用相关的值调用这个超类构造函数,新的规范将被完全初始化。
看看这个类的所有其他方法;它们所做的只是提供对成员变量值的访问。
现在,我们可以编写真正的规范来扩展这个类。
虽然我们将只实现本章中的播放器、激光器和后台组件类,但是我们现在将实现所有的规范类。在下一章中,他们将为我们编写与外星人相关的组件代码做好准备。
该规范确切地定义了哪些组件被组合成一个对象,以及其他属性,如标签、位图、速度和大小/比例。
让我们一个一个来看。您将需要为每一个创建一个新的类,但是我不需要继续提示您这样做了。
这指定了追赶玩家的外星人,一旦他们在一条线上或几乎在一条线上,就向他们发射激光。添加并检查以下代码,以便我们可以谈论它:
import android.graphics.PointF;
class AlienChaseSpec extends ObjectSpec {
// This is all the unique specifications
// for an alien that chases the player
private static final String tag = "Alien";
private static final String bitmapName =
"alien_ship1";
private static final float speed = 4f;
private static final PointF relativeScale =
new PointF(15f, 15f);
private static final String[] components = new String
[] {
"StdGraphicsComponent",
"AlienChaseMovementComponent",
"AlienHorizontalSpawnComponent"};
AlienChaseSpec(){
super(tag, bitmapName,
speed, relativeScale,
components);
}
}
标签变量被初始化为Alien
。所有的外星人,不管他们是什么类型,都会有一个Alien
的标签。正是它们不同的组成部分决定了它们不同的行为。Alien
的通用标签将足以让Physics
等级确定与玩家或玩家激光的碰撞。bitmapName
变量被初始化为alien_Ship1
。请随意检查drawable
文件夹,并确认这是代表追逐者的图形。
它将具有4
的速度,我们将在本章稍后开始对一些组件进行编码时,看到如何准确地将其转化为像素速度。
尺寸(relativeScale
)的PointF
实例被初始化为15f, 15f
。这意味着游戏对象及其位图将被缩放到屏幕宽度的十五分之一。我们将在本章后面对组件类进行编码时查看这方面的代码。
组件数组已由三个组件初始化:
StdGraphicsComponent
类处理draw
方法,游戏每一帧都可以调用。StdGraphicsComponent
类将实现GraphicsComponent
接口。请记住,正是通过这个界面,我们可以确定StdGraphicsComponent
将处理draw
方法。AlienChaseMovementComponent
类将掌握追逐者外星人如何追逐的逻辑。它将实现MovementComponent
接口,因此将保证处理move
方法,每次我们在GameObject
上调用update
时都会调用该方法。AlienHorizontalSpawnComponent
类将保存在屏幕外水平生成一个对象所需的逻辑。
显然,我们将不得不对所有这些表示组件的类以及它们实现的接口进行编码。
最后,我们用super…
调用超类构造函数,所有的值都被传递到的ObjectSpec
类构造函数中,在这里它们被初始化,以便准备在工厂中使用。
添加以下职业来指定潜水员外星人,它将不断扑向玩家试图摧毁他们:
import android.graphics.PointF;
class AlienDiverSpec extends ObjectSpec {
// This is all the unique specifications
// for an alien that dives
private static final String tag = "Alien";
private static final String bitmapName = "alien_ship3";
private static final float speed = 4f;
private static final PointF relativeScale =
new PointF(60f, 30f);
private static final String[] components = new String
[] {
"StdGraphicsComponent",
"AlienDiverMovementComponent",
"AlienVerticalSpawnComponent"};
AlienDiverSpec(){
super(tag, bitmapName,
speed, relativeScale,
components);
}
}
这个类是,格式和我们详细讨论过的上一个类一样。不同之处在于它有不同的位图和大小/比例。或许你会注意到最有意义的是,图形组件保持不变,但移动和产卵组件不同。
AlienDiverMovementComponent
类将包含潜水逻辑,而AlienVerticalSpawnComponent
将负责在屏幕顶部生成潜水员外星人。
接下来,添加外星激光的规格。这个将是一些外星人发射的炮弹:
import android.graphics.PointF;
class AlienLaserSpec extends ObjectSpec {
// This is all the unique specifications
// for an alien laser
private static final String tag = "Alien Laser";
private static final String bitmapName = "alien_laser";
private static final float speed = .75f;
private static final PointF relativeScale =
new PointF(14f, 160f);
private static final String[] components = new String
[] {
"StdGraphicsComponent",
"LaserMovementComponent",
"LaserSpawnComponent"};
AlienLaserSpec(){
super(tag, bitmapName,
speed, relativeScale,
components);
}
}
AlienLaserSpec
类也使用StdGraphicsComponent
,但是有自己的LaserMovementComponent
和LaserSpawnComponent
。
为了让获得可以在合适的时间调用的合适的方法,三个组件将分别实现GraphicsComponent
、MovementComponent
和SpawnComponent
接口。
这些基于激光的组件也将被PlayerLaserSpec
类使用,但是PlayerLaserSpec
类将具有不同的图形、不同的标签,并且速度也会稍微快一些。
我确信你能猜到这个类是巡逻者外星人的规范:
import android.graphics.PointF;
class AlienPatrolSpec extends ObjectSpec {
// This is all the unique specifications
// for a patrolling alien
private static final String tag = "Alien";
private static final String bitmapName = "alien_ship2";
private static final float speed = 5f;
private static final PointF relativeScale =
new PointF(15f, 15f);
private static final String[] components = new String
[] {
"StdGraphicsComponent",
"AlienPatrolMovementComponent",
"AlienHorizontalSpawnComponent"};
AlienPatrolSpec(){
super(tag, bitmapName,
speed, relativeScale,
components);
}
}
注意AlienPatrolSpec
类使用了一个独特的移动组件(AlienPatrolMovementComponent
)但是使用了StdGraphicsComponent
类并且使用了与追逐者外星人相同的AlienHorizontalSpawnComponent
类。
现在,是时候做一些有点不同的事情了。增加BackgroundSpec
类:
import android.graphics.PointF;
class BackgroundSpec extends ObjectSpec {
// This is all the unique specifications
// for the background
private static final String tag = "Background";
private static final String bitmapName = "background";
private static final float speed = 2f;
private static final PointF relativeScale =
new PointF(1f, 1f);
private static final String[] components = new String
[] {
"BackgroundGraphicsComponent",
"BackgroundMovementComponent",
"BackgroundSpawnComponent"};
BackgroundSpec() {
super(tag, bitmapName,
speed, relativeScale,
components);
}
}
这个类有三个全新的背景相关组件:
BackgroundGraphicsComponent
将负责并排绘制背景图像的两个副本。BackgroundMovementComponent
将注意移动屏幕上两个图像之间的连接,以给出滚动的错觉。这到底是如何工作的,我们将在本章后面对组件进行编码时讨论。BackgroundSpawnComponent
以背景必须是的独特方式生成游戏对象。
只剩下两个规范了,然后我们可以对接口和组件类进行编码。
这个类是给玩家的激光用的:
import android.graphics.PointF;
class PlayerLaserSpec extends ObjectSpec {
// This is all the unique specifications
// for a player laser
private static final String tag = "Player Laser";
private static final String bitmapName =
"player_laser";
private static final float speed = .65f;
private static final PointF relativeScale =
new PointF(8f, 160f);
private static final String[] components = new String
[] {
"StdGraphicsComponent",
"LaserMovementComponent",
"LaserSpawnComponent"};
PlayerLaserSpec(){
super(tag, bitmapName,
speed, relativeScale,
components);
}
}
这个类与AlienLaserSpec
类相同,只是它有一个绿色图形,一个不同的标签和一个更快的速度。
这个类规范有一个额外的组成部分。增加这个PlayerSpec
类,我们可以讨论一下:
import android.graphics.PointF;
class PlayerSpec extends ObjectSpec {
// This is all the unique specifications
// for a player
private static final String tag = "Player";
private static final String bitmapName = "player_ship";
private static final float speed = 1f;
private static final PointF relativeScale =
new PointF(15f, 15f);
private static final String[] components = new String
[] {
"PlayerInputComponent",
"StdGraphicsComponent",
"PlayerMovementComponent",
"PlayerSpawnComponent"};
PlayerSpec() {
super(tag, bitmapName,
speed, relativeScale,
components);
}
}
PlayerSpec
比其他规格更先进。它有一个共同的地方图形组件,但玩家特定的运动和产卵组件。
但是,请注意,有一种全新类型的组件。PlayerInputComponent
将处理屏幕触摸,还将作为观察员注册到GameEngineBroadcaster
( GameEngine
)班级。
现在,我们可以对组件接口进行编码。
每个组件将实现一个接口,这样尽管它们的行为不同,但由于的多态性,它们可以以相同的方式使用。
添加到GraphicsComponent
界面:
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PointF;
interface GraphicsComponent {
void initialize(Context c,
ObjectSpec s,
PointF screensize);
void draw(Canvas canvas,
Paint paint,
Transform t);
}
所有的图形组件都需要初始化(位图加载和缩放),并且每帧都要绘制。该界面的initialize
和draw
方法确保这种情况能够发生。这是由特定的图形相关组件类处理的。
请注意每种方法的参数。initialize
方法将获得一个Context
、一个特定的(派生的)ObjectSpec
和屏幕的大小。所有这些东西都将用于设置对象,以便可以绘制。
正如我们所料,draw
方法会收到一个Canvas
和一个Paint
。它还需要一个Transform
。正如我们在本章前面提到的,每个游戏对象都有一个Transform
实例,它保存着关于它在哪里、有多大以及它朝哪个方向行进的数据。在我们开始编码Transform
类之前,我们编码的每个接口都会有一个错误。
接下来,对InputComponent
界面进行编码:
interface InputComponent {
void setTransform(Transform t);
}
InputComponent
界面只有一个方法——即setTransform
方法。考虑到玩家拥有的各种按钮选项, InputComponent 相关类,PlayerInputComponent
将在处理屏幕触摸的方式上相当深入。但是InputComponent
界面唯一需要方便的是组件可以更新其相关的Transform
。setTransform
方法传入一个引用,然后组件可以操作标题、位置等等。
这是所有运动相关组件类将实现的接口;例如,PlayerMovementComponent
、LaserMovementComponent
,以及所有三个与外星人相关的运动组件。添加MovementComponent
界面:
interface MovementComponent {
boolean move(long fps,
Transform t,
Transform playerTransform);
}
只需要单一的方法;也就是move
。看看这些参数,它们非常重要。首先是帧率,这样所有的物体都可以根据帧的时长自行移动。这里没有什么新东西,但是move
方法也收到了两个Transform
参考。一个是GameObject
本身的Transform
参照物,一个是玩家的Transform
参照物。需要GameObject
类本身的Transform
引用,这样它就可以根据特定组件的任何逻辑来移动自己。
它之所以还需要玩家的GameObject
Transform
是因为外星人的大部分移动逻辑都取决于他们相对于玩家的位置。如果你不知道玩家在哪里,你就不能追他们或向他们开枪。
这是组件接口的最后一个。添加SpawnComponent
界面:
interface SpawnComponent {
void spawn(Transform playerTransform,
Transform t);
}
只需要一种方法;也就是spawn
。spawn
法还接收具体的GameObject
Transform
和玩家的Transform
。有了这个数据,游戏对象就可以用自己特定的逻辑,沿着跟玩家的位置,来决定在哪里产卵。
如果我们不实现它们,所有这些接口都是无用的。我们现在就开始吧。
为每个与玩家相关的组件编写一个空类将允许我们快速编写代码来运行游戏。然后,我们可以在进行过程中充实每个组件的真实/完整代码,而不需要多次进入同一个类(主要是GameObject
)。
在本章中,我们将讨论玩家(和他们的激光)和背景。对空轮廓进行编码也将允许我们对一个包含所有这些组件的无错误GameObject
类进行编码。通过这样做,在我们对每个组件内部的细节进行编码之前,我们可以看到组件是如何通过GameObject
类与游戏引擎进行交互的。
每个组件都将实现我们在上一节中编码的一个接口。我们将为每个类添加足够的代码来履行它对接口的约定义务,因此不会导致任何错误。我们还将在组件类之外进行非常小的更改,以使开发顺利进行,但是当我们到达适当的部分时,我将介绍细节。
我们将在本章稍后对GameObject
类进行编码后,将丢失的代码放入。如果你想偷偷看看各种组件方法的细节,你可以在本章后面的完成玩家和背景的组件部分。
让我们从所有组件类中使用最多的开始;那就是,StdGraphicsComponent
。添加新类和该类的起始代码,如下所示:
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PointF;
class StdGraphicsComponent implements GraphicsComponent {
private Bitmap mBitmap;
private Bitmap mBitmapReversed;
@Override
public void initialize(Context context,
ObjectSpec spec,
PointF objectSize){
}
@Override
public void draw(Canvas canvas,
Paint paint,
Transform t) {
}
}
有几条import
语句目前没有使用,但在本章结束时将全部使用。该类实现了GraphicsComponent
接口,因此它必须为initialize
和draw
方法提供一个实现。这些实现现在是空的,一旦我们编码了GameObject
和Transform
类,我们将返回到它们。
现在,对PlayerMovementComponent
类进行编码,该类实现MovementComponent
接口:
import android.graphics.PointF;
class PlayerMovementComponent implements MovementComponent {
@Override
public boolean move(long fps, Transform t,
Transform playerTransform){
return true;
}
}
务必添加返回true
的所需move
方法。
我们正在编码的所有组件类最初都将处于类似的半途而废的状态,直到我们在本章稍后重新访问它们。
接下来,将编码为近空PlayerSpawnComponent
,实现SpawnComponent
:
class PlayerSpawnComponent implements SpawnComponent {
@Override
public void spawn(Transform playerTransform, Transform
t) {
}
}
添加空spawn
方法以避免任何错误。
现在,我们可以将的轮廓编码到PlayerInputComponent
和的PlayerLaserSpawn
界面。我们将把它们放在一起讨论,因为它们相互关联。
从PlayerInputComponent
开始,我们将也给这个类增加几个成员变量;然后,我们将讨论它们。创建一个名为PlayerInputComponent
的新类并进行编码,如下所示:
import android.graphics.Rect;
import android.view.MotionEvent;
import java.util.ArrayList;
class PlayerInputComponent implements InputComponent,
InputObserver {
private Transform mTransform;
private PlayerLaserSpawner mPLS;
PlayerInputComponent(GameEngine ger) {
}
@Override
public void setTransform(Transform transform) {
}
// Required method of InputObserver
// interface called from the onTouchEvent method
@Override
public void handleInput(MotionEvent event,
GameState gameState,
ArrayList<Rect> buttons) {
}
}
请注意,该类实现了两个界面–InputComponent
和我们的老朋友来自 第 18 章**设计模式介绍等等!、InputObserver
。该代码实现了两个界面所需的方法,setTransform
和handleInput
(因此GameEngine
可以用玩家的屏幕交互来调用它)。
*还有一个实例成员和另一个成员将作为错误出现,直到我们很快对其进行编码。有一个名为mPLS
的成员属于PlayerLaserSpawner
类型。
将的思绪拉回到 第十八章**设计模式介绍等等!,当我们对GameStarter
界面进行编码时。我们对GameStarter
接口进行了编码,这样我们就可以将对它的引用传递到GameState
中。然后我们实现了接口,包括GameEngine
中的startNewGame
方法,从而允许GameState
调用GameEngine
类中的startNewGame
方法。
*我们现在将做一些类似的事情来允许PlayerInputComponent
类调用GameEngine
类中的一个方法并产生一个激光。
PlayerLaserSpawner
界面会有一种方法;也就是spawnPlayerLaser
。通过在PlayerInputComponent
中有一个PlayerLaserSpawner
的实例,我们将能够调用它的方法,并且让GameEngine
类在我们需要的时候产生激光。
新建一个类,对PlayerLaserSpawner
界面进行编码,如下图:
public interface PlayerLaserSpawner {
boolean spawnPlayerLaser(Transform transform);
}
切换到GameEngine
类并使其实现PlayerLaserSpawner
,如下图所示:
class GameEngine extends SurfaceView
implements Runnable,
GameStarter,
GameEngineBroadcaster,
PlayerLaserSpawner {
…
现在,在GameEngine
中添加所需的方法spawnPlayerLaser
。它现在将是空的:
@Override
public boolean spawnPlayerLaser(Transform transform) {
return false;
}
现在,我们可以继续下一个组件。
对LaserMovementComponent
类进行编码,实现MovementComponent
接口的:
import android.graphics.PointF;
class LaserMovementComponent implements MovementComponent {
@Override
public boolean move(long fps,
Transform t,
Transform playerTransform) {
return true;
}
}
类实现所需的move
方法。
对LaserSpawnComponent
类进行编码,实现SpawnComponent
接口的:
import android.graphics.PointF;
class LaserSpawnComponent implements SpawnComponent {
@Override
public void spawn(Transform playerTransform,
Transform t) {
}
}
该代码包括空但必需的spawn
方法。
对BackgroundGraphicsComponent
类进行编码,该类实现GraphicsComponent
接口:
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PointF;
import android.graphics.Rect;
class BackgroundGraphicsComponent implements GraphicsComponent {
private Bitmap mBitmap;
private Bitmap mBitmapReversed;
@Override
public void initialize(Context c,
ObjectSpec s,
PointF objectSize) {
}
@Override
public void draw(Canvas canvas,
Paint paint,
Transform t) {
}
}
这里已经实现了GraphicsComponent
、initialize
和draw
这两个必需的方法,这里还有两个成员变量,当我们在本章后面对类进行完整编码时,它们就可以使用了。
两个变量名和Matrix
类的导入暗示了我们将如何创建滚动背景效果。
对BackgroundMovementComponent
类进行编码,该类实现MovementComponent
接口:
class BackgroundMovementComponent implements MovementComponent {
@Override
public boolean move(long fps,
Transform t,
Transform playerTransform) {
return true;
}
}
这个代码包含了需要的move
方法。
对BackgroundSpawnComponent
类进行编码,该类实现SpawnComponent
接口:
class BackgroundSpawnComponent implements SpawnComponent {
@Override
public void spawn(Transform playerLTransform, Transform
t) {
}
}
请注意,它包含所需的spawn
方法。
我们现在已经有了本章结束时要完成的所有组件类的大纲。接下来,我们将对Transform
类进行编码。
正如我们在本章前面所学的,在实体-组件模式部分,每个GameObject
都将有一个Transform
类作为成员。Transform
类将保存所有数据,并执行所有GameObject
实例共有的所有操作,此外还有更多操作。在一个更完整的游戏引擎中,Transform
类将会是典型的小类,但是我们将会作弊并且使它变得相当大,以便合并一些典型的不属于Transform
的数据和方法。
我们这样做是为了防止代码结构变得更加复杂。在计划这本书的时候,这个项目的结构似乎已经变得更加复杂了。因此,这个类将是我们的组件类不处理的公共事物的一种总括。您会注意到并非所有GameObject
实例都需要所有的功能/数据。这不是一个最佳实践,但它将作为构建这个游戏的垫脚石,我们将改进Transform
类,使其在下一个也是最后一个项目中更加精致。
通常,一个Transform
类只包含大小、位置、方向和标题等数据,以及相关的方法。我们的还将包含一些东西,比如对撞机和一种帮助我们从船的正确部分发射激光的方法。
创建一个名为Transform
的新类,并添加以下import
语句、成员变量和构造函数:
import android.graphics.PointF;
import android.graphics.RectF;
class Transform {
// These two members are for scrolling background
private int mXClip;
private boolean mReversedFirst = false;
private RectF mCollider;
private PointF mLocation;
private boolean mFacingRight = true;
private boolean mHeadingUp = false;
private boolean mHeadingDown = false;
private boolean mHeadingLeft = false;
private boolean mHeadingRight = false;
private float mSpeed;
private float mObjectHeight;
private float mObjectWidth;
private static PointF mScreenSize;
Transform(float speed, float objectWidth,
float objectHeight,
PointF startingLocation,
PointF screenSize){
mCollider = new RectF();
mSpeed = speed;
mObjectHeight = objectHeight;
mObjectWidth = objectWidth;
mLocation = startingLocation;
mScreenSize = screenSize;
}
}
让我们重新列出所有这些成员,并快速解释他们将做什么。请注意,所有成员都是private
,因此我们需要大量的获取者和设置者来处理我们想要操作的对象:
-
int mXClip
:这是一个代表屏幕上两个代表背景的位图相遇的水平位置的值。如果这听起来有点奇怪,那么不要担心——所有这些都将在本章后面的编码滚动背景一节中解释。 -
boolean mReversedFirst
:这决定了代表背景的两个位图中哪个先画出来。其中一个位图将是另一个位图的反转版本。通过并排绘制它们,并改变它们在屏幕上相遇的位置,我们可以实现滚动效果。有关详细信息,请参见编码滚动背景部分。 -
RectF mCollider
:和我们在其他项目中做的一样,一个RectF
实例将代表物体所占据的区域,可以用来进行碰撞检测。 -
PointF mLocation
:这是游戏对象左上角的像素位置。它用于移动对象,以及确定在哪里绘制它。 -
boolean mFacingRight
: Is the object currently facing to the right. Several decisions in the logic of the component classes depend on which way the game object is facing or heading. Remember that all the movement-related component classes receive a reference to theirTransform
instance (and the player's) as a parameter in themove
method. The following are some more movement/heading-related Booleans:boolean mHeadingUp
:物体是否向上?boolean mHeadingDown
:物体是不是在往下走?boolean mHeadingLeft
:物体航向向左吗?boolean mHeadingRight
:物体航向对吗?float mSpeed
:物体行进的速度有多快(朝哪个方向)?float mObjectHeight
:物体有多高?float mObjectWidth
:物体有多宽? -
static PointF mScreenSize
:这个变量其实和Transform
本身没有什么关系;然而,Transform
往往指的是屏幕尺寸,所以保留一份数值是有意义的。请注意mScreenSize
是static
,所以它是类的变量,而不是实例,这意味着mScreenSize
只有一个副本在Transform
的所有实例中共享。
构造器接收大量数据,其中大部分来自规范类,并且它还接收起始位置的PointF
实例和屏幕大小的PointF
实例(以像素为单位)。接下来,代码初始化我们刚刚讨论过的一些成员变量。
让我们添加一些Transform
类的方法。将以下代码添加到Transform
类中:
// Here are some helper methods that the background will use
boolean getReversedFirst(){
return mReversedFirst;
}
void flipReversedFirst(){
mReversedFirst = !mReversedFirst;
}
int getXClip(){
return mXClip;
}
void setXClip(int newXClip){
mXClip = newXClip;
}
我们刚才添加的四个方法被GameObject
类用来表示滚动背景。它们允许组件类(通过其Transform
引用)获取值并更改/设置mXClip
和mReversedFirst
的值。
添加以下简单方法。一定要看一看它们的名称、返回值和它们操作的变量。它将使组件类的编码更容易理解:
PointF getmScreenSize(){
return mScreenSize;
}
void headUp(){
mHeadingUp = true;
mHeadingDown = false;
}
void headDown(){
mHeadingDown = true;
mHeadingUp = false;
}
void headRight(){
mHeadingRight = true;
mHeadingLeft = false;
mFacingRight = true;
}
void headLeft(){
mHeadingLeft = true;
mHeadingRight = false;
mFacingRight = false;
}
boolean headingUp(){
return mHeadingUp;
}
boolean headingDown(){
return mHeadingDown;
}
boolean headingRight(){
return mHeadingRight;
}
boolean headingLeft(){
return mHeadingLeft;
}
我们刚才添加的短方法如下:
getmScreenSize
:获取一个PointF
对象中屏幕的宽度和高度。headUp
:操纵方向相关变量,显示物体向上的方向。headDown
:操纵与方向相关的变量,显示物体向下的方向。headRight
:操纵方向相关变量,向右显示物体航向。headLeft
:操纵方向相关变量,向左显示物体航向。headingUp
:检查物体当前是否向上。headingDown
:检查对象当前是否向下。headingRight
:检查对象当前是否向右方向。headingLeft
:检查对象当前是否向左方向。
接下来,添加updateCollider
方法;然后,我们将讨论它:
void updateCollider(){
// Pull the borders in a bit (10%)
mCollider.top = mLocation.y + (mObjectHeight / 10);
mCollider.left = mLocation.x + (mObjectWidth /10);
mCollider.bottom = (mCollider.top + mObjectHeight)
- mObjectHeight/10;
mCollider.right = (mCollider.left + mObjectWidth)
- mObjectWidth/10;
}
updateCollider
方法使用游戏对象的位置、宽度和高度一个接一个地重新初始化RectF
实例的四个值。移动的物体在游戏的每一帧都会调用这个方法。
接下来是要添加到Transform
类的另一长串短方法。大多数都是不言自明的,但我们将简要解释每一个,以确保在继续之前理解它们的目的。也许比方法本身更有趣的是我们如何使用它们,当我们在本章后面对组件类进行编码时,这一点将变得显而易见:
float getObjectHeight(){
return mObjectHeight;
}
void stopVertical(){
mHeadingDown = false;
mHeadingUp = false;
}
float getSpeed(){
return mSpeed;
}
void setLocation(float horizontal, float vertical){
mLocation = new PointF(horizontal, vertical);
updateCollider();
}
PointF getLocation() {
return mLocation;
}
PointF getSize(){
return new PointF((int)mObjectWidth,
(int)mObjectHeight);
}
void flip(){
mFacingRight = !mFacingRight;
}
boolean getFacingRight(){
return mFacingRight;
}
RectF getCollider(){
return mCollider;
}
以下是您刚刚添加的方法的解释:
getObjectHeight
:返回对象的高度。stopVertical
:操纵成员变量停止上下移动。getSpeed
:返回游戏对象的速度。setLocation
:取一个PointF
作为参数。这包含将游戏对象移动到的位置,并更新mLocation
成员变量。getLocation
:返回一个包含游戏对象左上角像素位置的PointF
。getSize
:返回一个包含游戏对象宽度和高度的PointF
。flip
:改变/翻转游戏对象的水平朝向。getFacingRight
:游戏对象是面向右边的吗?getCollider
:返回RectF
,作为碰撞器。这被PhysicsEngine
类用来检测碰撞。
Transform
类的最后一个方法是getFiringLocation
方法。在此添加此方法;然后,我将解释它的功能:
PointF getFiringLocation(float laserLength){
PointF mFiringLocation = new PointF();
if(mFacingRight) {
mFiringLocation.x = mLocation.x
+ (mObjectWidth / 8f);
}else
{
mFiringLocation.x = mLocation.x
+ (mObjectWidth / 8f) - (laserLength);
}
// Move the height down a bit of ship height from
origin
mFiringLocation.y = mLocation.y + (mObjectHeight /
1.28f);
return mFiringLocation;
}
这种方法使用激光的长度、船只面对的方向、船只的大小和一些其他值来确定激光的产生点。这样做是必要的,因为如果你只使用mLocation
变量,激光会在飞船的左上角产卵,看起来有点傻。这种方法的计算使激光看起来像来自前方(尖点)。
只是重申一下,Transform
的一些功能和成员浪费在了我们的一些游戏对象上。比如一个激光不需要翻转也不需要飞起,除了背景之外的游戏物体都不需要水平剪裁,只有部分舰船需要getFiringPosition
法。从技术上来说,这是不好的做法,但是我们将细化Transform
类,并在下一个项目中使用继承使其更加高效。
最后,我们准备好为备受关注的GameObject
类编码。
这个类将成为我们各个组成部分的活呼吸(或飞射或潜水)组合。
创建GameObject
类,并添加如下所示的import
语句和构造函数:
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PointF;
class GameObject {
private Transform mTransform;
private boolean isActive = false;
private String mTag;
private GraphicsComponent graphicsComponent;
private MovementComponent movementComponent;
private SpawnComponent spawnComponent;
}
在这里,我们可以看到我们有一个名为mTransform
的Transform
类的实例。此外,我们还有一个名为isActive
的boolean
成员变量。这将作为对象当前是否正在使用的指示器。mTag
变量的值将与我们在编码所有特定对象规范部分中编码的规范类的标记相同。
我们声明的最后三个成员是最有趣的,因为它们是我们组件类的实例。请注意,类型被声明为GraphicsComponent
、MovementComponent
和SpawnComponent
接口类型。因此,不管游戏对象需要什么组件(适合玩家、外星人、背景或其他什么),这三个实例都是合适的。
将这些吸气剂和沉降剂加入GameObject
类;然后,我们将讨论它们:
void setSpawner(SpawnComponent s) {
spawnComponent = s;
}
void setGraphics(GraphicsComponent g, Context c,
ObjectSpec spec, PointF objectSize) {
graphicsComponent = g;
g.initialize(c, spec, objectSize);
}
void setMovement(MovementComponent m) {
movementComponent = m;
}
void setInput(InputComponent s) {
s.setTransform(mTransform);
}
void setmTag(String tag) {
mTag = tag;
}
void setTransform(Transform t) {
mTransform = t;
}
请注意我们刚刚添加的所有方法都初始化了一个或多个成员变量。setSpawner
方法使用作为参数传递的SpawnComponent
引用初始化SpawnComponent
实例。该实例可以是实现SpawnComponent
接口的任何类。
setGraphics
方法使用传入的引用(以及一些其他值)初始化GraphicsComponent
实例。与SpawnComponent
一样,GraphicsComponent
可以是实现GraphicsComponent
接口的任何类型。
setMovement
方法使用在MovementComponent
中传递的初始化MovementComponent
实例。同样,和前面两个方法一样,如果传入的引用实现了MovementComponent
接口,那么代码就会完成它的工作,无论它是AlienDiverMovementComponent
、AlienChaseMovement…
、AlienPatrolMovement…
、PlayerMovement…
、LaserMovement…
还是我们未来梦想的任何其他类型的…Movement…
类(也许是一个PinkElephantStampedingMovementComponent
)都没有关系。只要正确实现MovementComponent
界面,它在我们的游戏中就能正常工作。
setInput
方法有点不同,因为它使用传递给它的InputComponent
组件,并调用其setTransform
方法来传入mTransform
。InputComponent
现在有了一个引用合适的Transform
实例。请记住,只有玩家有InputComponent
,但如果我们扩展游戏,这可能会改变,这种安排将适应它。GameObject
类不需要依赖于对InputComponent
的引用;它只是在Transform
实例中通过,现在可以忘记它的存在。InputComponent
也必须注册成为Observer
到GameEngine
类(我们很快就会看到);然后,系统就会工作。
setmTag
方法初始化mTag
变量,setTransform
方法接收一个Transform
引用来初始化mTransform
。
此时可能困扰你的问题是,这些…Component
、Transform
和标签到底是从哪里来的?什么叫这些方法?答案是工厂。我们将很快编写代码的工厂类将知道什么游戏对象将具有什么组件。它将创建一个新的游戏对象,调用其set…
方法,然后将完美组装的GameObject
返回到另一个类,该类将保持一个ArrayList
充满所有可爱组装和多样的GameObject
引用。
这是艰难的一章,但我们正接近享受我们的劳动成果,所以让我们继续前进。
添加这三个关键方法,使用我们的三个关键组件:
void draw(Canvas canvas, Paint paint) {
graphicsComponent.draw(canvas, paint, mTransform);
}
void update(long fps, Transform playerTransform) {
if (!(movementComponent.move(fps,
mTransform, playerTransform))) {
// Component returned false`
isActive = false;
}
}
boolean spawn(Transform playerTransform) {
// Only spawnComponent if not already active
if (!isActive) {
spawnComponent.spawn(playerTransform,
mTransform);
isActive = true;
return true;
}
return false;
}
draw
方法使用GraphicsComponent
实例调用其draw
方法,update
方法使用MovementComponent
实例调用其move
方法,spawn
方法使用SpawnComponent
实例调用其spawn
方法。游戏对象是什么并不重要(外星人、激光、玩家、背景等等),因为特定的组件会知道如何相应地处理自己。
draw
方法会发送常用的Canvas
和Paint
引用,以及对Transform
的引用。
move
方法获得发送的所需当前对象的Transform
,以及玩家的Transform
参考。它还检查move
方法的返回值,以查看对象是否需要停用。
在组件上调用spawn
并将对象设置为激活之前,spawn
方法检查对象是否还未激活。
还有四种更简单的 getter 和 setter 方法。将它们添加到GameObject
类;然后,我们可以继续前进:
boolean checkActive() {
return isActive;
}
String getTag() {
return mTag;
}
void setInactive() {
isActive = false;
}
Transform getTransform() {
return mTransform;
}
我们刚刚添加的四个方法告诉我们对象是否是活动的,它的标签,以及它的Transform
实例。
我们现在可以开始添加代码,让我们的各种组件类做一些事情,因为它们还没有做任何事情。
所有的游戏对象都依赖于玩家或者对玩家做出反应。例如,外星人会相对于玩家的位置产卵、追逐和射击。甚至背景也会根据玩家正在做的事情来提示向哪个方向滚动。因此,正如我们前面提到的,让玩家先工作是有意义的。
然而,请记住使用实体-组件模式将意味着我们为玩家编码的一些组件也将在我们实现一些其他游戏对象时使用。
重要说明
如果我们没有在Transform
类之前以及随后的GameObject
之前对空的组件类进行编码,那么所有这些对Transform
类的调用以及这些组件工作的上下文可能会更难理解。
当我们对所有玩家和背景组件进行编码时,我将明确什么是新代码,以及我们在编码玩家和背景的空组件类部分编码了什么。
请记住尽管这一部分有标题,但其中一些组件也包含与玩家无关的游戏对象。我们只是先编写这些代码,因为让玩家马上工作是有意义的。
我们目前有两个空方法;即initialize
和draw
。让我们将代码添加到他们的身体中,从initialize
开始。新代码(方法主体中的所有内容)被突出显示。
将新代码添加到initialize
方法中:
@Override
public void initialize(
Context context,
ObjectSpec spec,
PointF objectSize){
// Make a resource id out of the string of the file
name
int resID = context.getResources()
.getIdentifier(spec.getBitmapName(),
"drawable",
context.getPackageName());
// Load the bitmap using the id
mBitmap = BitmapFactory.decodeResource(
context.getResources(), resID);
// Resize the bitmap
mBitmap = Bitmap
.createScaledBitmap(mBitmap,
(int)objectSize.x,
(int)objectSize.y,
false);
// Create a mirror image of the bitmap if needed
Matrix matrix = new Matrix();
matrix.setScale(-1, 1);
mBitmapReversed = Bitmap.createBitmap(mBitmap,
0, 0,
mBitmap.getWidth(),
mBitmap.getHeight(),
matrix, true);
}
所有的代码我们已经在initialize
方法中见过几次了。提醒一下,这就是正在发生的事情:getResources.getIdentifier
方法使用位图的名称来识别来自drawable
文件夹的图形文件。
该标识符随后被decodeResource
方法用于将图形加载到Bitmap
对象中。
接下来,使用createScaledBitmap
方法将Bitmap
对象缩放到游戏对象的正确大小。
最后,用另一个Bitmap
创建一个反转版本的Bitmap
。现在,我们可以用面向左或右的StdGraphicsComponent
实例显示任何GameObject
。
现在,将以下突出显示的代码添加到StdGraphicsComponent
类的draw
方法中:
@Override
public void draw(Canvas canvas,
Paint paint,
Transform t) {
if(t.getFacingRight())
canvas.drawBitmap(mBitmap,
t.getLocation().x,
t.getLocation().y,
paint);
else
canvas.drawBitmap(mBitmapReversed,
t.getLocation().x,
t.getLocation().y,
paint);
}
draw
方法中的代码使用Transform
类的getFacingRight
方法来决定是画Bitmap
使其面向右还是面向左。
这个类是解决方案的一部分,将使玩家的宇宙飞船复活。move
方法中的代码使用Transform
实例来确定船只的航向,并相应地移动船只。在几页的时间里,我们还将对PlayerInputComponent
类进行编码,该类将根据玩家的屏幕交互操作Transform
实例。这是响应这些交互的类。
在PlayerMovementComponent
类的move
方法中添加以下新的高亮代码:
@Override
public boolean move(long fps, Transform t,
Transform playerTransform){
// How high is the screen?
float screenHeight = t.getmScreenSize().y;
// Where is the player?
PointF location = t.getLocation();
// How fast is it going
float speed = t.getSpeed();
// How tall is the ship
float height = t.getObjectHeight();
// Move the ship up or down if needed
if(t.headingDown()){
location.y += speed / fps;
}
else if(t.headingUp()){
location.y -= speed / fps;
}
// Make sure the ship can't go off the screen
if(location.y > screenHeight - height){
location.y = screenHeight - height;
}
else if(location.y < 0){
location.y = 0;
}
// Update the collider
t.updateCollider();
return true;
}
在move
方法中发生的第一件事是使用 getter 方法从Transform
实例初始化一些局部变量。由于变量被使用了不止一次,代码将会比重复调用Transform
类的 getters 更加整洁和快速。此时,我们有一个变量来表示屏幕的高度、对象的位置、对象的速度和对象的高度。这些分别由screenHeight
、location
、speed
和height
局部变量表示。
接下来,在move
方法中,我们使用一个if
语句和一个else if
语句来确定船是向上还是向下。又来了:
// Move the ship up or down if needed
if(t.headingDown()){
location.y += speed / fps;
}
else if(t.headingUp()){
location.y -= speed / fps;
}
如果是,则通过speed / fps
上下移动船舶。下一对if
和else if
检查船只是离开屏幕顶部还是底部:
// Make sure the ship can't go off the screen
if(location.y > screenHeight - height){
location.y = screenHeight - height;
}
else if(location.y < 0){
location.y = 0;
}
如果它已经离开屏幕,location.y
被改变以反映屏幕上船只应该被允许到达的最低(screenHeight-height
)或最高(0
)点。
注意我说最高和最低的时候,这个稍微有点暧昧。屏幕上的最高点由最低的数字(像素位置零)表示,而屏幕上的最低点是较高的数字。
最后一行代码调用updateCollider
方法,这样碰撞器就可以根据船只的新位置进行更新。
这是一个非常简单的组件。每当调用GameObject
实例的spawn
方法时,该代码都会执行。添加以下突出显示的代码:
@Override
public void spawn(Transform playerTransform, Transform t) {
// Spawn in the centre of the screen
t.setLocation(
t.getmScreenSize().x/2,
t.getmScreenSize().y/2);
}
我们需要做的就是用setLocation
方法把飞船放在屏幕中间。中间是用高度和宽度除以二计算出来的。
这门课相当长,但一次学一点不会太复杂。首先,在PlayerInputComponent
构造函数和setTransform
方法中添加以下代码:
PlayerInputComponent(GameEngine ger) {
ger.addObserver(this);
mPLS = ger;
}
@Override
public void setTransform(Transform transform) {
mTransform = transform;
}
构造函数接收对GameEngine
类的引用,使用addObserver
方法注册为观察者。现在,这个类将在玩家每次触摸屏幕时接收触摸细节。
在setTransform
方法中,mTransform
是引用GameObject
的Transform
部分。请记住,GameObject
类被工厂类传递了一个InputController
引用(GameObjectFactory
,我们将很快对其进行编码),并使用该引用来调用该方法。
既然mTransform
是对实际Transform
的引用,而实际Transform
是玩家飞船的GameObject
类的一部分,handleInput
方法可以用它来操纵它。请记住,我们在handleInput
方法中操作Transform
实例,并且PlayerMovementComponent
对这些操作做出响应。再提醒一下,GameEngine
类中的onTouchEvent
方法调用handleInput
方法,因为PlayerInputComponent
类注册为观察者。
在handleInput
方法中添加以下高亮显示的代码;然后,我们将讨论它。请务必查看传入的参数,因为它们是我们讨论该方法如何工作的关键:
// Required method of InputObserver
// and is called from the onTouchEvent method
@Override
public void handleInput(MotionEvent event,
GameState gameState,
ArrayList<Rect> buttons) {
int i = event.getActionIndex();
int x = (int) event.getX(i);
int y = (int) event.getY(i);
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_UP:
if (buttons.get(HUD.UP).contains(x,y)
|| buttons.get(HUD.DOWN).contains(x,y)) {
// Player has released either up or
down
mTransform.stopVertical();
}
break;
case MotionEvent.ACTION_DOWN:
if (buttons.get(HUD.UP).contains(x,y)) {
// Player has pressed up
mTransform.headUp();
} else if (buttons.get(
HUD.DOWN).contains(x,y)) {
// Player has pressed down
mTransform.headDown();
} else if (buttons.get(
HUD.FLIP).contains(x,y)) {
// Player has released the flip button
mTransform.flip();
} else if (buttons.get(
HUD.SHOOT).contains(x,y)) {
mPLS.spawnPlayerLaser(mTransform);
}
break;
case MotionEvent.ACTION_POINTER_UP:
if (buttons.get(HUD.UP).contains(x, y)
||
buttons.get(HUD.DOWN).contains(x, y)) {
// Player has released either up or
down
mTransform.stopVertical();
}
break;
case MotionEvent.ACTION_POINTER_DOWN:
if (buttons.get(HUD.UP).contains(x, y)) {
// Player has pressed up
mTransform.headUp();
} else if (buttons.get(
HUD.DOWN).contains(x, y)) {
// Player has pressed down
mTransform.headDown();
} else if (buttons.get(
HUD.FLIP).contains(x, y)) {
// Player has released the flip button
mTransform.flip();
} else if (buttons.get(
HUD.SHOOT).contains(x, y)) {
mPLS.spawnPlayerLaser(mTransform);
}
break;
}
}
让我们将handleInput
方法的内部分解成易于管理的块。首先,我们使用getActionIndex
、getX
和getY
方法来确定触发该方法被调用的触摸的坐标。这些值现在存储在x
和y
变量中:
int i = event.getActionIndex();
int x = (int) event.getX(i);
int y = (int) event.getY(i);
现在,我们进入switch
块,它决定动作类型。我们处理四个case
语句。这是新的。之前我们只处理了两个案件:ACTION_UP
和ACTION_DOWN
。不同的是,多个手指可以同时互动。让我们看看我们如何处理这个问题,以及四个case
声明是什么:
switch (event.getAction() & MotionEvent.ACTION_MASK) {
}
第一种说法并不新鲜。ACTION_UP
被处理,我们只对被释放的上下按钮感兴趣。如果松开上升或下降按钮,则调用stopVertical
方法,下一次调用PlayerMovementComponent
的移动方法时,船不会上升或下降:
case MotionEvent.ACTION_UP:
if (buttons.get(HUD.UP).contains(x,y)
|| buttons.get(HUD.DOWN).contains(x,y)) {
// Player has released either up or down
mTransform.stopVertical();
}
break;
接下来我们来处理ACTION_DOWN
,这个案例稍微宽泛一些。我们需要控制所有的船只。每个if
- else
块处理在特定按钮内计算x
和y
时发生的情况。仔细看看下面的代码:
case MotionEvent.ACTION_DOWN:
if (buttons.get(HUD.UP).contains(x,y)) {
// Player has pressed up
mTransform.headUp();
} else if (buttons.get(HUD.DOWN).contains(x,y)) {
// Player has pressed down
mTransform.headDown();
} else if (buttons.get(HUD.FLIP).contains(x,y)) {
// Player has released the flip button
mTransform.flip();
} else if (buttons.get(HUD.SHOOT).contains(x,y)) {
mPLS.spawnPlayerLaser(mTransform);
}
break;
按下 up 时,调用headUp
方法。按下时,调用headDown
方法。按下翻转时调用flip
方法,按下拍摄时使用mPLS
调用GameEngine
类上的spawnPlayerLaser
方法。
如果你看一下接下来的两个case
语句,它们紧挨着出现,看起来会很熟悉。事实上,除了每个case
的第一行代码之外,case
语句的代码与前面两个case
语句相同。
你会注意到我们现在响应的不是ACTION_UP
和ACTION_DOWN
,而是ACTION_POINTER_UP
和ACTION_POINTER_DOWN
。对此的解释很简单。如果第一根手指接触屏幕导致动作被触发,则它被MotionEvent
物体作为ACTION_UP
或ACTION_DOWN
握住。如果是第二个、第三个、第四个等等,那么就保持为ACTION_POINTER_UP
或者ACTION_POINTER_DOWN
。这在我们早期的任何一款游戏中都无关紧要,我们已经能够避免额外的代码:
case MotionEvent.ACTION_POINTER_UP:
if (buttons.get(HUD.UP).contains(x, y)
||
buttons.get(HUD.DOWN).contains(x, y)) {
// Player has released either up or down
mTransform.stopVertical();
}
break;
case MotionEvent.ACTION_POINTER_DOWN:
if (buttons.get(HUD.UP).contains(x, y)) {
// Player has pressed up
mTransform.headUp();
} else if (buttons.get(HUD.DOWN).contains(x, y)) {
// Player has pressed down
mTransform.headDown();
} else if (buttons.get(HUD.FLIP).contains(x, y)) {
// Player has released the flip button
mTransform.flip();
} else if (buttons.get(HUD.SHOOT).contains(x, y)) {
mPLS.spawnPlayerLaser(mTransform);
}
break;
在我们的游戏中,不管是不是POINTER
都没有关系,只要我们对所有的按压和释放做出反应。玩家可以交叉双臂玩游戏——这对滚动射击游戏没有任何影响。
然而,如果您检测到更复杂的手势,如捏、缩放或一些自定义触摸,那么顺序——甚至是触摸屏幕时的时间和移动——可能很重要。MotionEvent
类可以处理所有这些情况,但在本书中我们不需要这样做。
让我们把注意力转向激光。
我们已经对PlayerLaserSpawner
接口进行了编码,通过GameEngine
类实现了它,对PlayerInputComponent
进行了编码,使它接收到一个PlayerLaserSpawner
实例,然后在玩家按下屏幕拍摄按钮时调用spawnPlayerLaser
方法(在GameEngine
上)。此外,我们还在Transform
类(getFiringLocation
)中编写了一个辅助方法,根据玩家船只的位置和方向,计算出一个美观的位置来产生激光。
为了实现所有这些,我们需要对激光器本身的组件类别进行编码。将以下高亮显示的代码添加到LaserMovementComponent
类的move
方法中:
@Override
public boolean move(long fps,
Transform t,
Transform playerTransform) {
// Laser can only travel two screen widths
float range = t.getmScreenSize().x * 2;
// Where is the laser
PointF location = t.getLocation();
// How fast is it going
float speed = t.getSpeed();
if(t.headingRight()){
location.x += speed / fps;
}
else if(t.headingLeft()){
location.x -= speed / fps;
}
// Has the laser gone out of range
if(location.x < - range || location.x > range){
// disable the laser
return false;
}
t.updateCollider();
return true;
}
move
方法中的新代码初始化三个局部变量:range
、location
和speed
。它们使用激光器的Transform
参考进行初始化。他们的名字不言自明,也许除了range
。range
变量由屏幕宽度(t.getmScreensize.x
)乘以 2 初始化。我们将使用该值来监控何时关闭激光器。
接下来,在move
方法中,我们可以看到一些与PlayerMovementComponent
类非常相似的代码。有一个if
和一个else
- if
模块检测激光向哪个方向前进(t.headingRight
或t.headingLeft
)。在if
和else
- if
模块内,使用speed / fps
沿适当方向水平移动激光器。
下一个if
程序块使用这里显示的公式检查是否到了关闭激光器的时间:
if(location.x < - range || location.x > range){
if
语句检测屏幕宽度是否在左侧或右侧超过了两倍。如果有,则move
方法返回假。回想一下我们在GameObject
类的update
方法中调用move
方法的时候——当move
方法返回false
时,GameObject
的mIsActive
成员被设置为false
。在这个GameObject
上将不再调用move
方法。
move
方法的最后一行代码使用updateCollider
方法将激光对撞机更新到新位置。
激光的最后一位代码是LaserSpawnComponent
的spawn
方法。如果你是想知道激光将如何绘制自己,请参考PlayerLaserSpec
课;你会看到它使用了StdGraphicsComponent
,我们已经对其进行了编码。
在spawn
方法中添加以下新的高亮代码:
@Override
public void spawn(Transform playerTransform,
Transform t) {
PointF startPosition =
playerTransform.getFiringLocation(
t.getSize().x);
t.setLocation((int)startPosition.x,
(int)startPosition.y);
if(playerTransform.getFacingRight()){
t.headRight();
}
else{
t.headLeft();
}
}
新代码做的第一件事是通过在玩家的Transform
引用上调用getFiringLocation
方法来初始化一个名为startPosition
的PointF
。另外,请注意,激光的大小被传递给getFiringLocation
方法,这是该方法进行计算所需的。
接下来,在激光器的Transform
参考上调用setLocation
方法,并且startPosition
现在持有的水平和垂直值被用作参数。
最后,在if
- else
语句中使用玩家的航向来决定激光航向的设置方式。如果玩家面朝右,激光也将朝右(反之亦然)是有道理的。
在这一点上,激光已经准备好被牵引和移动。
游戏的第一帧显示的是背景图,如下图。这没有受到任何操纵:
图 20.2–游戏的背景图像
下一帧的显示方式是将图像移出屏幕向左。那么,我们在屏幕右侧最后一个像素宽的垂直列上显示什么呢?我们将制作同一图像的反转副本,并将其显示在原始(未反转)图像的右侧。下图显示了两个图像之间的间隙,以清楚地表明存在连接以及两个图像的位置,但实际上,我们将图像放在彼此旁边,这样连接就不可见了:
图 20.3–连接两幅图像
随着原始图像和反转图像稳定地向左滚动,最终将显示每个图像的一半,以此类推:
图 20.4–连接图像后的屏幕
最终,我们将到达原始图像的末尾,反转图像右侧的最后一个像素将最终显示在屏幕上。
此时,当反转的图像在屏幕上完整显示时,就像原始图像在开始时一样,我们将原始图像移到右侧。这两个背景将连续滚动,当右手图像(原始或反转)成为玩家看到的整个视图时,左手图像(原始或反转)将移动到右手侧,准备滚动到视图中。
请注意,当向相反方向滚动时,整个过程是相反的!
在下一个项目中,我们还将在滚动背景前绘制平台和其他场景,从而创建一个整洁的视差效果。
现在我们知道了滚动背景需要实现什么,我们可以开始编码三个与背景相关的组件类。
让我们从BackgroundGraphicsComponent
课开始。我们必须编码的第一个方法是initialize
方法。将以下突出显示的代码添加到initialize
方法中:
@Override
public void initialize(Context c,
ObjectSpec s,
PointF objectSize) {
// Make a resource id out of the string of the file
name
int resID = c.getResources()
.getIdentifier(s.getBitmapName(),
"drawable", c.getPackageName());
// Load the bitmap using the id
mBitmap = BitmapFactory
.decodeResource(c.getResources(), resID);
// Resize the bitmap
mBitmap = Bitmap
.createScaledBitmap(mBitmap,
(int)objectSize.x,
(int)objectSize.y,
false);
// Create a mirror image of the bitmap
Matrix matrix = new Matrix();
matrix.setScale(-1, 1);
mBitmapReversed = Bitmap
.createBitmap(mBitmap,
0, 0,
mBitmap.getWidth(),
mBitmap.getHeight(),
matrix, true);
}
前面的代码是我们还没有看到的。使用位图的名称选择资源,使用decodeResource
方法加载资源,使用createScaledBitmap
方法缩放资源,然后结合使用Matrix
类和createBitmap
方法创建图像的反转版本。我们现在有两个Bitmap
对象(mBitmap
和mBitmapReversed
)准备进行绘图。
现在,我们可以对draw
方法进行编码,在游戏的每一帧都会调用这个方法来绘制背景。在draw
方法中添加以下新的高亮代码;然后,我们可以讨论它:
@Override
public void draw(Canvas canvas,
Paint paint,
Transform t) {
int xClip = t.getXClip();
int width = mBitmap.getWidth();
int height = mBitmap.getHeight();
int startY = 0;
int endY = (int)t.getmScreenSize().y +20;
// For the regular bitmap
Rect fromRect1 = new Rect(0, 0, width - xClip,
height);
Rect toRect1 = new Rect(xClip, startY, width, endY);
// For the reversed background
Rect fromRect2 = new Rect(width - xClip, 0, width,
height);
Rect toRect2 = new Rect(0, startY, xClip, endY);
//draw the two background bitmaps
if (!t.getReversedFirst()) {
canvas.drawBitmap(mBitmap,
fromRect1, toRect1, paint);
canvas.drawBitmap(mBitmapReversed,
fromRect2, toRect2, paint);
} else {
canvas.drawBitmap(mBitmap, fromRect2,
toRect2, paint);
canvas.drawBitmap(mBitmapReversed,
fromRect1, toRect1, paint);
}
}
我们在新的draw
方法代码中做的第一件事情是声明一些局部变量,帮助我们在正确的地方绘制两幅图像。
通过使用Transform
引用调用getXclip
来初始化xClip
变量。xClip
的值是决定图像中连接位置的关键。Transform
持有这个值,它在BackgroundMovementComponent
中被操纵,我们接下来将对其进行编码。
width
变量从Bitmap
缩放到的宽度开始初始化。
height
变量从Bitmap
缩放到的高度开始初始化。
startY
变量是我们想要开始绘制图像的垂直点。这很简单——在屏幕顶部——零。它被相应地初始化。
endY
变量位于屏幕底部,初始化为屏幕高度,加上 20 个像素,确保没有小故障。
接下来,我们初始化四个Rect
对象。当我们将位图绘制到屏幕上时,我们将需要为每个Bitmap
创建两个Rect
对象:一个从确定要绘制的位图部分*,另一个确定要绘制到的屏幕区域。因此,我们将Rect
对象命名为fromRect1
、toRect1
、fromRect2
和toRect2
。再看看这四行代码:*
// For the regular bitmap
Rect fromRect1 = new Rect(0, 0, width - xClip, height);
Rect toRect1 = new Rect(xClip, startY, width, endY);
// For the reversed background
Rect fromRect2 = new Rect(width - xClip, 0, width, height);
Rect toRect2 = new Rect(0, startY, xClip, endY);
首先,请注意,出于解释的目的,我们可以忽略四个Rect
对象的所有垂直值。垂直值是第二个和第四个参数,它们总是startY
和endY
。
可以看到fromRect1
总是从零开始水平延伸到全宽,少了不管xClip
的值是多少。
跳到fromRect2
,我们可以看到它总是从全幅开始,少了xClip
,延伸到全幅。
试着在脑海中想象一下xClip
值增加时,第一个图像会水平缩小,第二个图像会变大。随着xClip
值的降低,情况正好相反。
现在,把你的注意力转向toRect1
。我们可以看到图像是从xClip
画到屏幕上的,不管width
是什么。现在,看看toRect2
,看到它是从宽度,少了xClip
,到无论width
是什么。这些值的作用是根据图像当前的宽度将图像精确地并排放置,并确保这些宽度正好覆盖屏幕的整个宽度。
作者考虑了解释如何计算这些Rect
值的不同方法,并建议为了绝对清楚地说明这是如何工作的,读者应该使用铅笔和纸来计算和绘制xClip
不同值的矩形。一旦你完成了背景相关组件的编码,看看xClip
是如何操作的,这个练习将会非常有用。
代码的最后一部分使用Transform
参考来确定应该在左侧(首先)绘制哪个图像,然后使用drawBitmap
和四个先前计算的Rect
对象绘制两个图像。
这是的重载版本drawBitmap
,它采用要绘制的Bitmap
属性、要绘制的图像的一部分(fromRect1
和fromRect2
)以及屏幕目的地坐标(toRect1
和toRect2
)。
接下来,我们将移动背景。这主要是通过增加和减少Transform
类中的mXClip
来实现的,但也可以通过切换图像的绘制顺序来实现。将以下突出显示的代码添加到BackgroundMovementComponent
类的move
方法中:
@Override
public boolean move(long fps,
Transform t,
Transform playerTransform) {
int currentXClip = t.getXClip();
if(playerTransform.getFacingRight()) {
currentXClip -= t.getSpeed() / fps;
t.setXClip(currentXClip);
}
else {
currentXClip += t.getSpeed() / fps;
t.setXClip(currentXClip);
}
if (currentXClip >= t.getSize().x) {
t.setXClip(0);
t.flipReversedFirst();
}
else if (currentXClip <= 0) {
t.setXClip((int)t.getSize().x);
t.flipReversedFirst();
}
return true;
}
代码做的第一件事是从Transform
引用中获取裁剪/连接位置的当前值,并将其存储在currentXClip
局部变量中。
第一个if
- else
区块测试玩家是面向左还是面向右。如果玩家正面对着右边,currentXClip
会被背景速度降低,除以当前帧率。如果玩家面向左侧,currentXClip
将被背景速度增加,除以当前帧率。在这两种情况下,setXClip
方法用于更新Transform
中的mXClip
。
代码中下一个是if
- else
- if
区块。这测试currentXClip
是大于背景宽度还是小于零。如果currentXClip
大于背景的宽度,则setXClip
用于将其设置回零,图像的顺序用flipReversedFirst
翻转。如果currentXClip
小于或等于零,则setXClip
用于将其设置为背景宽度,图像的顺序用flipReversedFirst
翻转。
方法总是返回true
,因为我们从来不想去激活背景。
现在,我们只需要生成背景。然后,我们可以开始在工厂工作,该工厂将把所有这些组件组合并构造成GameObject
实例。
在BackgroundSpawnComponent
的spawn
方法中添加以下高亮显示的代码:
@Override
public void spawn(Transform playerLTransform, Transform t) {
// Place the background in the top left corner
t.setLocation(0f,0f);
}
一行代码将背景的位置设置在屏幕的左上角。
但是等一下!我们还没有实例化一个游戏对象。为此,我们还需要两个班;这将完成我们的游戏对象生产线。第一个是工厂本身,而下一个是Level
类,你可以编辑这个类来决定游戏是什么样子的(敌人有多少和哪种类型)。你甚至可以扩展它来制作一个有多个不同关卡的游戏。
一旦这两个职业完成,将很容易在游戏中添加许多不同的外星人。设计并添加你自己的…Spec
类,编写它们的组件类,并将其添加到游戏中,这也是微不足道的。
使用基于对象规范的类来组装具有正确组件的GameObject
实例是GameObjectFactory
类的工作。
创建一个名为GameObjectFactory
的新类,并添加以下成员和构造函数:
import android.content.Context;
import android.graphics.PointF;
class GameObjectFactory {
private Context mContext;
private PointF mScreenSize;
private GameEngine mGameEngineReference;
GameObjectFactory(Context c, PointF screenSize,
GameEngine gameEngine) {
this.mContext = c;
this.mScreenSize = screenSize;
mGameEngineReference = gameEngine;
}
}
这里,我们有一个Context
对象,一个PointF
对象保存屏幕分辨率,一个GameEngine
对象保存对GameEngine
类的引用。所有这些成员变量都在构造函数中初始化。正是Level
类将创建并使用对该类的引用。正是GameEngine
类将创建Level
类的实例,并为Level
类提供必要的引用来调用这个GameObjectFactory
构造函数。
接下来,添加create
方法。这将完成创建GameObject
实例的所有艰苦工作。我们将很快向该方法添加更多代码:
GameObject create(ObjectSpec spec) {
GameObject object = new GameObject();
int numComponents = spec.getComponents().length;
final float HIDDEN = -2000f;
object.setmTag(spec.getTag());
// Configure the speed relative to the screen size
float speed = mScreenSize.x / spec.getSpeed();
// Configure the object size relative to screen
size
PointF objectSize =
new PointF(mScreenSize.x /
spec.getScale().x,
mScreenSize.y / spec.getScale().y);
// Set the location to somewhere off-screen
PointF location = new PointF(HIDDEN, HIDDEN);
object.setTransform(new Transform(speed,
objectSize.x,
objectSize.y, location,
mScreenSize));
// More code here next...
}
首先,查看在create
方法上的签名,注意它接收到一个ObjectSpec
引用。请记住ObjectSpec
是抽象的,不能实例化,所以这意味着这必须是对扩展ObjectSpec
的类的引用——玩家、背景、外星人或激光。最后,我们可以使用以下代码创建一个新实例:
GameObject object = new GameObject();
接下来,我们将计算出规范中有多少组件包含在这一行代码中:
int numComponents = spec.getComponents().length;
我们可以创建一个很快就会有用的名为HIDDEN
的int
,并用下面的代码将其初始化为-2000
:
final float HIDDEN = -2000f;
现在,我们可以使用setTag
方法将规范中的标签存储在GameObject
实例中,如下所示:
object.setmTag(spec.getTag());
如果在这个方法中我的解释看起来有点费力,那么不要担心——这是故意的。这个类,特别是create
方法,是这个庞大章节中所有工作的汇集之处。正是在这里,Transform
、ObjectSpec
子类、GameObject
和众多组件类最终相互作用,创造出有意义的“东西”,在游戏中真正有所作为,我想确保你不会错过任何一个技巧。实体-组件模式与简单工厂模式的融合是构建您自己的深度游戏的关键,而不会被包含数千行杂乱无章、难以管理的代码的类所困扰。
下面几行代码根据屏幕宽度和我们当前构建的规范中的速度来声明和初始化speed
变量:
// Configure the speed relative to the screen size
float speed = mScreenSize.x / spec.getSpeed();
下面几行代码根据屏幕的宽度和我们当前构建的规范的大小,声明并初始化一个名为objectSize
的PointF
:
// Configure the object size relative to screen size
PointF objectSize =
new PointF(mScreenSize.x / spec.getScale().x,
mScreenSize.y / spec.getScale().y);
现在,我们将创建另一个名为location
的PointF
,并使用HIDDEN
变量将其初始化为-2000
、-2000
:
// Set the location to somewhere off-screen
PointF location = new PointF(HIDDEN, HIDDEN);
create
方法中的最后一段代码(到目前为止)通过调用我们新的GameObject
上的setTransform
变量来放置我们刚刚初始化使用的所有变量。以下是我所指的代码行:
object.setTransform(new Transform(speed, objectSize.x,
objectSize.y, location, mScreenSize));
// More code here next...
GameObject
类现在有一个完全初始化的Transform
。现在,该是组件的时候了。
在create
方法的右花括号内添加以下代码。请注意代码顶部突出显示的注释,它指示了新代码相对于我们在上一步中添加的代码的位置:
// More code here next...
// Loop through and add/initialize all the components
for (int i = 0; i < numComponents; i++) {
switch (spec.getComponents()[i]) {
case "PlayerInputComponent":
object.setInput(new PlayerInputComponent
(mGameEngineReference));
break;
case "StdGraphicsComponent":
object.setGraphics(new
StdGraphicsComponent(),
mContext, spec, objectSize);
break;
case "PlayerMovementComponent":
object.setMovement(new
PlayerMovementComponent());
break;
case "LaserMovementComponent":
object.setMovement(new
LaserMovementComponent());
break;
case "PlayerSpawnComponent":
object.setSpawner(new
PlayerSpawnComponent());
break;
case "LaserSpawnComponent":
object.setSpawner(new
LaserSpawnComponent());
break;
case "BackgroundGraphicsComponent":
object.setGraphics(new
BackgroundGraphicsComponent(),
mContext, spec, objectSize);
break;
case "BackgroundMovementComponent":
object.setMovement(new
BackgroundMovementComponent());
break;
case "BackgroundSpawnComponent":
object.setSpawner(new
BackgroundSpawnComponent());
break;
default:
// Error unidentified component
break;
}
}
// Return the completed GameObject to the Level class
return object;
让我们更仔细地看看那些for
和switch
条件。他们又来了:
for (int i = 0; i < mNumComponents; i++) {
switch (spec.getComponents()[i]) {
…
这段代码将遍历我们正在构建的当前规范中的组件数组中的每个组件。请记住,不是所有的规格都有相同数量的组件,但是由于numComponents
等于数组的长度,for
循环会处理这个问题。
switch
条件根据组件的名称进行切换。如果我们编写一个case
来匹配我们想要使用的每种类型的组件,那么我们将处理它们。我们添加的case
语句只处理我们已经编码的组件。一旦我们编写了更多的组件类,我们将在下一章中添加更多的case
语句。让我们看看每一个case
的陈述。
以下是前三个:
case "PlayerInputComponent":
object.setInput(new PlayerInputComponent
(mGameEngineReference));
break;
case "StdGraphicsComponent":
object.setGraphics(new StdGraphicsComponent(),
mContext, spec, objectSize);
break;
case "PlayerMovementComponent":
object.setMovement(new PlayerMovementComponent());
break;
检测到PlayerInputComponent
时,调用GameObject
的setInput
方法。在该方法中,传递了一个新的PlayerInputComponent
引用,在PlayerInputComponent
构造函数中,传递了GameEngine
引用。这有两个作用。首先PlayerInputComponent
使用GameEngine
引用来调用addObserver
,其次GameObject
实例可以使用PlayerInputComponent
引用来调用setTransform
方法,并传递PlayerInputComponent
需要的Transform
引用。
下一个case
通过调用setGraphics
方法创建一个新的StdGraphicsComponent
。GameObject
实例通过存储引用并调用initialize
方法来准备要绘制的对象。
接下来,通过调用setMovement
并传入新的PlayerMovementComponent
引用来创建PlayerMovementComponent
。
当LaserMovementComponent
出现时,下一个case
语句执行。case
调用setMovement
的方式与调用PlayerMovementComponent
的方式完全相同,只是LaserMovementComponent
被创建并传递给GameObject
而不是PlayerMovementComponent
:
case "LaserMovementComponent":
object.setMovement(new LaserMovementComponent());
break;
接下来的两个case
语句处理激光和玩家产卵组件。所有这些case
语句需要做的就是调用setSpawner
并传入适当的种子相关类。下面这两个cases
再次供参考:
case "PlayerSpawnComponent":
object.setSpawner(new PlayerSpawnComponent());
break;
case "LaserSpawnComponent":
object.setSpawner(new LaserSpawnComponent());
break;
接下来的三个case
语句处理所有与背景相关的组件。它们的创建方式与其他图形、移动和衍生相关组件完全相同,只是创建了用于创建背景的适当的新组件类,然后将其传递给GameObject
实例:
case "BackgroundGraphicsComponent":
object.setGraphics(new BackgroundGraphicsComponent(),
mContext, spec, objectSize);
break;
case "BackgroundMovementComponent":
object.setMovement(new BackgroundMovementComponent());
break;
case "BackgroundSpawnComponent":
object.setSpawner(new BackgroundSpawnComponent());
break;
最后一个case
可能会添加一些错误处理,以向控制台输出一条消息:
default:
// Error unidentified component
break;
最后一行代码返回对工厂刚刚构建的GameObject
实例的引用:
// Return the completed GameObject to the Level class
return object;
现在,我们将看到我们在哪里调用create
方法,以及我们在哪里存储create
方法返回的所有GameObject
引用。
Level
类是你设计关卡的地方。如果你想要更多特定类型的敌人或者更少的激光来降低射速,那么这就是你应该做的。在你计划发布的一个游戏中,你可能会extend Level
用不同的敌人、数量和背景设计多个实例。对于这个项目,我们将只坚持一个刚性级别,但在下一个项目中,我们将进一步推进级别设计理念。
创建一个名为Level
的类,并添加以下所有成员和import
语句:
import android.content.Context;
import android.graphics.PointF;
import java.util.ArrayList;
class Level {
// Keep track of specific types
public static final int BACKGROUND_INDEX = 0;
public static final int PLAYER_INDEX = 1;
public static final int FIRST_PLAYER_LASER = 2;
public static final int LAST_PLAYER_LASER = 4;
public static int mNextPlayerLaser;
public static final int FIRST_ALIEN = 5;
public static final int SECOND_ALIEN = 6;
public static final int THIRD_ALIEN = 7;
public static final int FOURTH_ALIEN = 8;
public static final int FIFTH_ALIEN = 9;
public static final int SIXTH_ALIEN = 10;
public static final int LAST_ALIEN = 10;
public static final int FIRST_ALIEN_LASER = 11;
public static final int LAST_ALIEN_LASER = 15;
public static int mNextAlienLaser;
// This will hold all the instances of GameObject
private ArrayList<GameObject> objects;
}
我们刚刚编码的大部分变量是public
、static
和final
。它们将被用来追踪我们产生了多少外星人和激光,以及追踪我们的GameObject ArrayList
中某些物体的存储位置。因为它们是public
、static
和final
,所以它们可以很容易地被引用,但不能从任何类中修改。
有两个变量不是final
。这是mNextPlayerLaser
和mNextAlienLaser
。我们将使用这些来循环我们产生的激光,以确定下一个拍摄哪一个。
前面代码中的最后一个声明是一个名为objects
的GameObject
实例的ArrayList
。这是我们将隐藏GameObjectFactory
类为我们组装的所有实例的地方。
接下来,添加构造函数方法:
public Level(Context context,
PointF mScreenSize,
GameEngine ge){
objects = new ArrayList<>();
GameObjectFactory factory = new GameObjectFactory(
context, mScreenSize, ge);
buildGameObjects(factory);
}
ArrayList
在构造函数内部初始化。接下来,创建GameObjectFactory
类的新实例。最后一行代码调用buildGameObjects
方法,并传入这个名为factory
的新GameObjectFactory
实例。
现在,对buildGameObjects
方法进行编码:
ArrayList<GameObject> buildGameObjects(
GameObjectFactory factory){
objects.clear();
objects.add(BACKGROUND_INDEX, factory
.create(new BackgroundSpec()));
objects.add(PLAYER_INDEX, factory
.create(new PlayerSpec()));
// Spawn the player's lasers
for (int i = FIRST_PLAYER_LASER;
i != LAST_PLAYER_LASER + 1; i++) {
objects.add(i, factory
.create(new PlayerLaserSpec()));
}
mNextPlayerLaser = FIRST_PLAYER_LASER;
// Create some aliens
// Create some alien lasers
return objects;
}
ArrayList<GameObject> getGameObjects(){
return objects;
}
首先,清除ArrayList
,以防这不是第一次调用该方法。我们不希望更新和绘制两个或更多级别的对象。
我们再来看看下面一行代码;如果我们能理解它,我们就能理解所有GameObject
实例是如何创建的:
objects.add(BACKGROUND_INDEX, factory
.create(new BackgroundSpec()));
代码以objects.add
开头,用于给ArrayList
添加新的引用。第一个参数是BACKGROUND_INDEX
并指出ArrayList
中我们希望背景走向的位置。
传递给add
方法的第二个参数需要是对GameObject
的引用。就是这样,但是有点复杂。这是代码:
factory.create(new BackgroundSpec())
这将调用GameObjectFactory
实例上的create
方法,您可能还记得该方法返回所需类型的GameObject
。所以,在这个阶段,有一个适当构建的背景以GameObject
的形式等待更新和绘制。
接下来,我们以同样的方式添加一个玩家,然后我们从FIRST_PLAYER_LASER
到LAST_PLAYER_LASER
循环一个for
循环,然后添加一束激光让玩家拍摄。
mNextPlayerLaser
变量被初始化为FIRST_PLAYER_LASER
的值,准备好让GameEngine
类在需要时生成它。
最后一种方法是getGameObjects
法。它返回对objects
数组列表的引用。这就是我们如何与其他需要的班级分享objects
。
我们只需要解决一些小问题,这样我们就可以运行游戏了。
向GameEngine
添加一个Level
类的实例:
...
HUD mHUD;
Renderer mRenderer;
ParticleSystem mParticleSystem;
PhysicsEngine mPhysicsEngine;
Level mLevel;
在GameEngine
构造函数中初始化Level
的实例:
public GameEngine(Context context, Point size) {
super(context);
mUIController = new UIController(this, size);
mGameState = new GameState(this, context);
mSoundEngine = new SoundEngine(context);
mHUD = new HUD(size);
mRenderer = new Renderer(this);
mPhysicsEngine = new PhysicsEngine();
mParticleSystem = new ParticleSystem();
mParticleSystem.init(1000);
mLevel = new Level(context,
new PointF(size.x, size.y), this);
}
现在,我们可以为我们在 第 18 章**设计模式介绍中为签名编码的deSpawnRespawn
方法添加一些代码,还有更多!。记住这个方法是GameState
类在新游戏需要开始的时候调用的。添加以下突出显示的代码:
public void deSpawnReSpawn() {
// Eventually this will despawn
// and then respawn all the game objects
ArrayList<GameObject> objects =
mLevel.getGameObjects();
for(GameObject o : objects){
o.setInactive();
}
objects.get(Level.PLAYER_INDEX)
.spawn(objects.get(Level.PLAYER_INDEX)
.getTransform());
objects.get(Level.BACKGROUND_INDEX)
.spawn(objects.get(Level.PLAYER_INDEX)
.getTransform());
}
前面的代码创建了一个名为objects
的新ArrayList
,并通过在Level
实例上调用getGameObjects
方法来初始化它。一个for
循环遍历每个GameObject
实例,并将它们设置为非活动状态。
接下来我们在播放器上调用spawn
方法,然后在后台调用spawn
方法。我们使用来自Level
类的public static final
变量来确保我们使用的是正确的指数。
现在,我们可以对我们添加的用于产生玩家激光的方法的主体进行编码:
public boolean spawnPlayerLaser(Transform transform) {
ArrayList<GameObject> objects =
mLevel.getGameObjects();
if (objects.get(Level.mNextPlayerLaser)
.spawn(transform)) {
Level.mNextPlayerLaser++;
mSoundEngine.playShoot();
if (Level.mNextPlayerLaser ==
Level.LAST_PLAYER_LASER + 1) {
// Just used the last laser
Level.mNextPlayerLaser =
Level.FIRST_PLAYER_LASER;
}
}
return true;
}
spawnPlayerLaser
方法通过调用getGameObjects
创建并初始化对我们的GameObject
实例的ArrayList
的引用。
代码然后试图从Level.mNextPlayerLaser
索引处的物体中产生激光。如果成功,Level.mNextPlayerLaser
递增,准备下一个镜头,并使用SoundEngine
类播放声音效果。
最后,还有一个嵌套的if
语句,用于检查是否使用了LAST_PLAYER_LASER
,如果使用了,则将mNextPlayerLaser
设置回FIRST_PLAYER_LASER
。
现在,更新run
方法,从Level
类获取所有游戏对象。然后,把它们传到PhysicsEngine
班,再传到Renderer
班。此处突出显示了新的和更改的线条:
@Override
public void run() {
while (mGameState.getThreadRunning()) {
long frameStartTime = System.currentTimeMillis();
ArrayList<GameObject> objects =
mLevel.getGameObjects();
if (!mGameState.getPaused()) {
// Update all the game objects here
// in a new way
// This call to update will evolve
// with the project
if(mPhysicsEngine.update(mFPS,objects,
mGameState,
mSoundEngine, mParticleSystem)){
// Player hit
deSpawnReSpawn();
}
}
// Draw all the game objects here
// in a new way
mRenderer.draw(objects, mGameState, mHUD,
mParticleSystem);
// Measure the frames per second in the usual way
long timeThisFrame = System.currentTimeMillis()
- frameStartTime;
if (timeThisFrame >= 1) {
final int MILLIS_IN_SECOND = 1000;
mFPS = MILLIS_IN_SECOND / timeThisFrame;
}
}
}
这个新的代码将包含错误,直到我们下一步更新PhysicsEngine
和Renderer
类。
将这些新的和修改过的高亮代码行添加到update
方法中:
// This signature and much more will change later in the project
boolean update(long fps, ArrayList<GameObject> objects,
GameState gs, SoundEngine se,
ParticleSystem ps){
// Update all the GameObjects
for (GameObject object : objects) {
if (object.checkActive()) {
object.update(fps, objects.get(
Level.PLAYER_INDEX)
.getTransform());
}
}
if(ps.mIsRunning){
ps.update(fps);
}
return false;
}
第一个变化是我们让方法签名接受一个GameObject
实例的ArrayList
。下一个变化是一个增强的for
循环,遍历objects ArrayList
中的每个项目,检查它们是否处于活动状态,并检查它们是否调用了它们的update
方法。
此时,每一帧的所有对象都在更新。我们只需要能看到他们。
用以下新的和修改过的行更新GameEngine
中的draw
方法:
void draw(ArrayList<GameObject> objects, GameState gs,
HUD hud, ParticleSystem ps) {
if (mSurfaceHolder.getSurface().isValid()) {
mCanvas = mSurfaceHolder.lockCanvas();
mCanvas.drawColor(Color.argb(255, 0, 0, 0));
if (gs.getDrawing()) {
// Draw all the game objects here
for (GameObject object : objects) {
if(object.checkActive()) {
object.draw(mCanvas, mPaint);
}
}
}
if(gs.getGameOver()) {
// Draw a background graphic here
objects.get(Level.BACKGROUND_INDEX)
.draw(mCanvas, mPaint);
}
// Draw a particle system explosion here
if(ps.mIsRunning){
ps.draw(mCanvas, mPaint);
}
// Now we draw the HUD on top of everything else
hud.draw(mCanvas, mPaint, gs);
mSurfaceHolder.unlockCanvasAndPost(mCanvas);
}
}
首先,我们更新了方法签名,以接收包含所有对象的ArrayList
实例。接下来,有一个增强的for
循环,依次获取每个对象,检查是否活动,以及它是否调用其draw
方法。最后一个小改动是在if(gs.getGameOver)
块增加一行代码,在游戏暂停时绘制背景(仅限)。
现在,让我们运行游戏。
最后,你可以运行游戏,在后台看到城市飞速而过:
图 20.5–运行游戏
测试暂停按钮,看看你能不能暂停游戏,测试翻转按钮,让飞船反方向飞行,一定要快速点击开火按钮,测试你的速射激光。
如果你还没有在真正的设备上运行我们在这本书中创建的任何游戏,那么现在是时候尝试一下了,因为模拟器不会特别流畅地滚动。
这不是一个容易的章节。如果你还不明白所有不同的类和接口是如何相互连接的,那也没关系。如果需要的话,可以回去重读介绍实体-组件模式和介绍简单工厂模式部分,以及学习我们到目前为止所学的代码。然而,当我们编写更多的组件并完成游戏时,继续这个项目也会有同样的帮助。
如果你不完全清楚部件和工厂,不要花太长时间挠头——继续进步;使用这些概念会比思考它们更快地清晰。
当我计划这本书的时候,我想在蛇的游戏后停下来。其实第一版通过搭建安卓游戏学习 Java在蛇游戏之后确实停了。从第二版开始,我认为这是不公平的,因为你已经有了足够的知识来开始实现一个更大的游戏,但是你的知识有足够的差距,以至于类和方法有数百或数千行代码。
请记住,我们正在学习处理的结构中的这种复杂性是一种非常值得的权衡,它与杂乱无章、不可读、有缺陷的类和方法进行了权衡。一旦你掌握了某些模式(包括最终项目中的更多模式)以及它们是如何工作的,你将无法阻止你能够计划和实施的游戏。
在下一章中,我们将通过对剩余的组件类进行编码,并通过Level
和GameObjectFactory
类将新对象添加到游戏中来完成这个游戏。我们还将增强PhysicsEngine
类来处理碰撞检测。***