安卓游戏编程示例(二)

原文:zh.annas-archive.org/md5/B228CC957519C7ABCD7559EDEA0B426A

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:平台游戏 - 升级游戏引擎

欢迎来到这本书的第二个项目。在这里,我们将构建一个真正困难的复古平台游戏。它不是难以构建,而是当你玩它时难以击败。在项目结束时,我们还将讨论如何使游戏玩法稍微不那么严苛,如果你希望的话。

本章将完全聚焦于我们的游戏引擎,本质上将导致 Tappy Defender 代码的升级版本。

首先,我们将讨论我们希望通过这个游戏实现的目标:背景故事、游戏机制和规则。

然后,我们将快速创建一个活动,实例化一个将完成所有工作的视图。

之后,我们将充实PlatformView类的基本结构,它将有一些微妙的但重要的区别于我们的TDView类。最值得注意的是,PlatformView将有一个简单但有效的方式来管理我们游戏所有事件的时间。

然后,我们将开始迭代构建我们的GameObject类,游戏世界中的几乎每一个实体都将由此派生。

接下来,我们将讨论视口的概念,玩家通过这个视口来观看游戏世界。我们不再将游戏对象设计为在屏幕分辨率层面操作,而是存在于一个拥有自身xy坐标的世界中,我们可以将这些坐标视为虚拟米。在z轴上也有一个简单的深度系统。这将由我们新的Viewport类来处理。

在此之后,我们将研究如何设计和布局游戏内容。这是通过一个用作关卡设计师的类完成的,可以非编程地使用它来规划跳跃、敌人、奖励和目标,这些构成了一个关卡的布局。

为了管理关卡设计并将它们加载到我们的游戏引擎中,我们将需要另一个类。我们将它称为LevelManager

在本章的最后,我们将查看PlatformView类中的增强型updatedraw方法,这样我们就可以实际运行我们的新游戏,并在屏幕上看到首次输出。

有这么多事情要做,我们最好开始吧。

游戏

我们将要构建的游戏基于一些 80 年代残酷难度的平台游戏,如 Bounty Bob Strikes Back 和 Impossible Mission 的游戏玩法。这些游戏以难以跳跃和同时需要极其精确的时机控制著称,同时给玩家一个不宽恕的生命/机会数量。这种游戏风格很适合我们,因为我们可以实际上在四个章节内构建一个多级别的可玩游戏。

类的设计将使你能够轻松添加自己的额外功能、游戏对象,或者如果你愿意,也可以稍微降低游戏的难度。

背景故事

我们的英雄鲍勃刚从地球中心摧毁一个邪恶科学家的秘密任务中回来,发现他正处于地下深处。更糟的是,尽管他已经击败了邪恶科学家,但似乎来不及拯救这个星球免受他释放的强大守卫和致命的飞行机器人无人机的侵袭。

鲍勃必须从深地下的火焰洞穴出发,穿过重兵把守的城市和山区森林,他希望在那里过上自由的生活,摆脱接管这个星球的可怕新秩序。

在这四个关卡中,他必须避开守卫,摧毁无人机,收集大量金钱,并升级他最初弱小的机枪。

游戏机制

游戏将围绕执行精确的跳跃,规划通过关卡的最佳路径以收集战利品并逃脱。鲍勃将能够小心翼翼地站在边缘,脚只有几个像素悬空,以完成看似不可能的跳跃。鲍勃将能够控制跳跃时的距离,这意味着有时他需要确保自己不会跳过头。

鲍勃在尝试通过重兵把守的区域逃脱前,需要收集机枪升级。

鲍勃只有三条生命,但在他的旅程中可能会找到更多。

游戏规则

当鲍勃被无人机/守卫捕获、触碰到火焰,或者跌出游戏世界而失去生命时,他将在当前关卡的起点重新出现。无人机可以飞行,并且一旦鲍勃进入视线就会锁定他。鲍勃需要确保他有足够的火力来对付无人机。守卫将在关卡预定区域巡逻,但他们很强大,只能被鲍勃的机枪击退。通常,鲍勃需要执行一个精确计时跳跃以绕过守卫。

环境同样会非常艰难。鲍勃需要完全掌握每个关卡,因为一次错误的跳跃就会让他直接回到起点,落入敌人手中,甚至直接遭遇火葬。

升级游戏引擎

所有的守卫、无人机、火焰、收藏品、枪支的讨论,以及暗示的更大游戏世界,表明我们需要管理一个更为复杂的系统。我们的游戏引擎的目标之一就是让这种复杂性易于管理。另一个目标是将关卡设计从编码中分离出来。当我们的游戏完成时,你将能够轻松设计出最邪恶但也最有成就感的关卡,在不同的环境中无需触碰代码就能完成设计。

平台活动

首先,我们从Activity类开始,这是进入我们游戏的入口点。这里没有太多新内容,让我们快速构建它。创建一个新项目,在应用名称字段中输入C5 平台游戏。选择手机和平板,然后在提示时选择空白活动。在活动名称字段中,输入PlatformActivity

提示

显然,您不必遵循我的确切命名选择,但请记得在代码中进行一些小修改,以反映您自己的命名选择。

您可以从layout文件夹中删除activity_platform.xml。您还可以删除PlatformActivity.java文件中的所有代码。只保留包声明。现在,我们有一个完全空白的画布,准备开始编码。以下是到目前为止我们的项目的全部内容:

package com.gamecodeschool.c5platformgame;

让我们开始构建我们的引擎。就像在我们的 Tappy Defender 项目中一样,我们将构建一个类来处理游戏视图方面。或许不足为奇,我们将这个类称为PlatformView。因此,我们的PlatformActivity类需要实例化一个PlatformView对象,并将其设置为应用程序的主要视图,就像在之前的项目中一样。

我们将对引擎进行一些重大升级,但主要是在视图层面进行。在接下来要看的PlatformActivity类的代码中,我们与上一个项目所做的类似。首先,在重写的onCreate方法中声明PlatformView对象,并将其设置为主要的视图;但在这样做之前,我们还需要捕获并传入设备屏幕的分辨率。

我们通过使用Display类,链式调用getWindowManager()getDefaultDisplay()方法来获取我们游戏将要运行的物理显示硬件的属性。然后,我们创建一个名为resolutionPoint类型的对象,并通过调用display.getSize(size)将显示的分辨率存储到我们的Point对象中。

这会将屏幕的水平像素数和垂直像素数分别存储在size.xsize.y中。然后我们可以继续通过调用其构造函数并传入size.xsize.y中存储的值来实例化一个新的PlatformView对象。与之前一样,我们还需要传入应用程序的Context对象(this),正如在之前的项目中,我们会发现它有很多用途。

我们可以通过调用setContentView()方法,将platformView设置为视图。如前所述,我们重写Activity类的生命周期方法onPause()onResume(),让它们调用我们即将编写的PlatformView类中的相应方法。这两个方法可以启动和停止我们的Thread类。

下面是我们刚刚讨论的PlatformActivity类的完整代码,没有新的重要方面。将代码输入或复制粘贴到您的项目中。本章的代码可以在 Packt Publishing 网站的书籍页面下载捆绑包中找到。本章的所有代码和资源都可以在Chapter5文件夹中找到。这个文件叫做PlatformActivity.java

提示

当提示导入所有新类时,请记得导入,或者当因缺少类而出现错误时,将光标悬停在错误上,按Alt | Enter键盘组合进行导入。

import android.app.Activity;
import android.graphics.Point;
import android.os.Bundle;
import android.view.Display;

public class PlatformActivity extends Activity {

    // Our object to handle the View
    private PlatformView platformView;

    @Override
    protected 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);

        // And finally set the view for our game
        // Also passing in the screen resolution
        platformView = new PlatformView
        (this, resolution.x, resolution.y);

        // Make our platformView the view for the Activity
        setContentView(platformView);

    }

    // If the Activity is paused make sure to pause our thread
    @Override
    protected void onPause() {
        super.onPause();
        platformView.pause();
    }

    // If the Activity is resumed make sure to resume our thread
    @Override
    protected void onResume() {
        super.onResume();
        platformView.resume();
    }
}

注意

显然,在我们创建PlatformView类之前,我们的PlatformActivity类代码中将会出现错误。

将布局锁定为横屏

正如我们在上一个项目中做的那样,我们将确保游戏只在横屏模式下运行。我们将使我们的AndroidManifest.xml文件强制我们的PlatformActivity类以全屏运行,并且我们还将将其锁定为横屏布局。让我们进行以下更改:

  1. 现在打开manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开它。

  2. AndroidManifest.xml文件中,找到以下代码行:

    android:name=".PlatformActivity"
    
  3. 在它下面,输入或复制粘贴以下两行代码,使PlatformActivity全屏运行,并将其锁定为横屏方向。

    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    android:screenOrientation="landscape"
    

现在,我们可以进入游戏的核心部分,看看我们如何实现我们讨论的所有这些改进。

PlatformView 类

到完成时,这个类将依赖于许多其他类。我不想逐一介绍每个类,因为这样会很难跟上,而且哪些代码实现了哪个功能也会变得混乱。相反,我们将根据需要逐个查看和编写每个功能,并多次回顾许多类以添加更多功能。这将使代码每一部分的特定目的保持焦点。

说到这里,我们已经非常注意,尽管我们会多次回顾这些类,但我们不会不断地删除代码,而只是在原有代码中增加内容。当我们增加代码时,将在适当的上下文中展示代码,并将新部分在现有代码中突出显示。

至于类的结构,它们被设计为尽可能最小,同时也不会限制你轻松添加功能和扩展代码的潜力。

这不是关于游戏引擎设计的课程,而是更多地学习如何实现和压缩四个章节中的不同功能,而不会使代码变得难以管理。

如果你计划构建非常大的游戏,尤其是在团队中工作时,那么更健壮的设计将是必要的。这种更健壮的设计也将意味着大量的额外类、接口、包等等。

提示

如果这类讨论吸引了你,我强烈推荐你阅读 Mario Zechner 所著的《Beginning Android Games》,由 APRESS 出版。Mario 是跨平台游戏库 LibGDX 的创始人/创造者,他的书详细介绍了构建高度可扩展和可重用游戏代码库所需的设计模式。这本书详细的设计细节的唯一缺点是,它需要大约 600 页来构建一个简单的复古贪吃蛇游戏。

首先,让我们创建一个类。在 Android Studio 项目浏览器中右键点击包名,选择New | Java Class。将新类命名为PlatformView。删除类中自动生成的代码,因为我们将很快添加自己的代码。

在整个项目过程中,我们将会继续向这个类添加代码。本章中添加到类中的完整代码可以在下载包中的Chapter5/PlatformView.java找到。

我们需要一个能够管理我们关卡的类。让我们称它为LevelManager

我们还需要一个类来保存我们关卡的数据,这样每次我们创建一个新/不同的关卡设计时,都可以扩展它。让我们将父类称为LevelData,而 Bob 逃脱的第一个真实关卡称为LevelCave

此外,由于这个游戏将有许多敌人、道具和地形类型,我们需要一个更清洁的系统来管理它们。我们需要一个相当通用的GameObject类,所有不同的游戏对象都可以继承它。这样,我们在updatedraw方法中可以更容易地管理它们。

同样,由于必要性,我们将构建一个稍微复杂一些的方法来检测玩家的输入。我们将创建一个InputController类,将所有代码从PlatformView委托给它。但是,我们将在下一章中完全展开我们的Player对象来表示玩家之后,才会了解这个类的细节。

我们可以快速编写基本的PlatformView类,其代码与第一个项目非常相似,但有几个值得注意的区别,我们将在后面讨论。

PlatformView的基本结构

这里是必要的导入和我们开始需要的成员变量。随着项目的进行,我们将会增加这些内容。

请注意,我们还声明了三种新的对象类型,lm是我们的LevelManager类,vp是我们的Viewport类,以及ic,它是我们的InputController类。我们将在本章中开始处理其中一些内容。当然,在我们实现它们各自的类之前,这些声明将显示错误。

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class PlatformView extends SurfaceView 
  implements Runnable {

  private boolean debugging = true;
  private volatile boolean running;
  private Thread gameThread = null;

  // For drawing
  private Paint paint;
  // Canvas could initially be local.
  // But later we will use it outside of draw()
  private Canvas canvas;
  private SurfaceHolder ourHolder;

  Context context;
  long startFrameTime;
  long timeThisFrame;
  long fps;

   // Our new engine classes
   private LevelManager lm;
   private Viewport vp;
   InputController ic;

在这里,我们有我们的PlatformView构造函数。在这个阶段,它没有做任何新的操作,实际上,它的代码比我们的TDView构造函数还要少,但它很快就会得到增强。现在,请输入如下代码:

PlatformView(Context context, int screenWidth, 
    int screenHeight) {

    super(context);
    this.context = context;

    // Initialize our drawing objects
    ourHolder = getHolder();
    paint = new Paint();
}

这是我们的线程的run方法。注意,在调用update()之前,我们获取当前时间(毫秒)并将其放入startFrameTime长整型变量中。然后在draw()完成之后,我们再次调用以获取系统时间,并测量自帧开始以来已经过去了多少毫秒。然后我们执行计算fps = 1000 / thisFrameTime,这给了我们上一个帧中游戏运行的帧数。这个值存储在fps变量中。随着游戏的进行,我们将到处使用这个值。编写我们刚刚讨论的run方法,如下所示:

@Override
public void run() {

  while (running) {
       startFrameTime = System.currentTimeMillis();

       update();
       draw();

      // Calculate the fps this frame
      // We can then use the result to
      // time animations and movement.
      timeThisFrame = System.currentTimeMillis() - startFrameTime;
            if (timeThisFrame >= 1) {
                fps = 1000 / timeThisFrame;
            }
     }
}

在本章后面,我们将看到如何管理多种对象类型的额外复杂性,并在必要时更新它们。现在,只需向PlatformView类添加一个空的update方法,如下所示:

private void update() {
  // Our new update() code will go here
}

在这里,我们看到我们熟悉的draw方法的部分。在本章后面,我们将看到一些新代码。现在,添加draw方法的基本部分,如下所示,这部分将保持不变:

private void draw() {

     if (ourHolder.getSurface().isValid()){
      //First we lock the area of memory we will be drawing to
      canvas = ourHolder.lockCanvas();

      // Rub out the last frame with arbitrary color
      paint.setColor(Color.argb(255, 0, 0, 255));
      canvas.drawColor(Color.argb(255, 0, 0, 255));

      // New drawing code will go here

      // Unlock and draw the scene
      ourHolder.unlockCanvasAndPost(canvas);
  }
}

视图第一阶段组合的最后部分是pauseresume方法,这些方法是由操作系统调用相应的 Activity 生命周期方法时由PlatformActivity调用的。它们与上一个项目中的方法没有变化,但为了完整性和便于跟踪,这里再次列出。将这些方法添加到PlatformView类中:

// Clean up our thread if the game is interrupted    
public void pause() {
  running = false;
   try {
       gameThread.join();
   } catch (InterruptedException e) {
       Log.e("error", "failed to pause thread");
   }
}

// Make a new thread and start it
// Execution moves to our run method
public void resume() {
   running = true;
   gameThread = new Thread(this);
   gameThread.start();

}

}// End of PlatformView

现在,我们已经完成了视图的基本大纲编码并准备就绪。让我们首先看看GameObject类。

GameObject

我们知道我们需要一个父类来保存我们游戏对象的大部分内容,因为我们想要改进上一个项目中代码的灵活性和重复性。从上一个项目我们知道,它需要许多属性和方法。

首先,我们需要一个简单的类来表示所有未来GameObject类的世界位置。这个类将在xy轴上保存一个详细的位置。请注意,这些与我们的游戏将运行的设备上的像素坐标完全独立。我们可以将z坐标视为图层编号。数字越小,越先绘制。因此,创建一个新的 Java 类,将其命名为Vector2Point5D,并输入以下代码:

public class Vector2Point5D {

    float x;
    float y;
    int z;
}

现在,让我们看看并编码GameObject类的基本工作大纲,然后在项目过程中,我们可以回过头来添加额外的功能。创建一个新的 Java 类,将其命名为GameObject。让我们看看我们需要开始编写使这个类有用的代码。首先,我们导入所需的类。

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;

当我们编写GameObject本身时,请注意该类没有提供构造函数,因为这将根据我们实现的特定GameObject而有所不同。

你在代码中注意到的第一个变量是worldLocation,正如你所预期的,它是Vector2Point5D类型的。然后我们有两个 float 成员,将保存GameObject类的宽度和高度。接下来是布尔变量activevisible,它们可能用于标记对象在活动、可见或其它状态时的标签。我们将在本章后面看到这样做的好处。

我们还需要知道任何给定的对象有多少内部动画帧。默认值将是1,因此animFrameCount相应地初始化。

然后,我们有一个名为typechar类。这个type变量将确切地确定任何特定的GameObject可能是什么。它将被广泛使用。目前最后一个成员变量是bitmapName。我们将看到,知道代表我们每个单独对象外观的图形的名称将非常有用。添加我们刚刚讨论的成员变量:

public abstract class GameObject {

    private Vector2Point5D worldLocation;
    private float width;
    private float height;

    private boolean active = true;
    private boolean visible = true;
    private int animFrameCount = 1;
    private char type;

    private String bitmapName;

现在,我们可以看看GameObject功能的第一部分。我们有一个抽象方法update()。我们的计划是所有对象都需要更新自身。在四章内容中,这显得有些过于雄心勃勃,我们的一些对象(主要是平台和场景)将只提供一个空的update()实现。但是,这并不妨碍你让场景比我们现在有时间处理的更具互动性,或者在我们了解事物如何运作后,让平台更具动态性和冒险性。添加以下抽象update方法:

public abstract void update(long fps, float gravity);

我们处理管理我们图形的方法。我们有一个获取器来检索bitmapName。然后,我们有一个prepareBitmap()方法,它使用字符串bitmapName.png图像文件制作一个 Android 资源 ID。这个文件必须存在于项目的drawable文件夹中。就像我们之前看到的那样创建位图。

现在,我们的prepareBitmap方法做了些新的事情。它使用createScaledBitmap方法来改变我们刚刚创建的位图的大小。它不仅使用我们之前讨论的animFrameCount,还使用方法的参数pixelsPerMetre变量。

想法是,每个设备都有一个适合该设备的pixelsPerMetre值,这将帮助我们跨不同分辨率的设备创建一个相同的游戏视图。当我们讨论Viewport类时,我们将确切地了解我们从哪里获取这个pixelsPerMetre值。在GameObject类中输入以下方法:

public String getBitmapName() {
        return bitmapName;
}

public Bitmap prepareBitmap(Context context, 
    String bitmapName, 
    int pixelsPerMetre) {

   // Make a resource id from the bitmapName
   int resID = context.getResources().
        getIdentifier(bitmapName,
        "drawable", context.getPackageName());

    // Create the bitmap
    Bitmap bitmap = BitmapFactory.
        decodeResource(context.getResources(),
        resID);

    // Scale the bitmap based on the number of pixels per metre
    // Multiply by the number of frames in the image
    // Default 1 frame
    bitmap = Bitmap.createScaledBitmap(bitmap,
                (int) (width * animFrameCount * pixelsPerMetre),
                (int) (height * pixelsPerMetre),
                false);

    return bitmap;
}

我们还希望能够知道每个GameObject在世界的哪个位置,当然,也要设置它在世界的哪个位置。以下是一个获取器和设置器,它们正好实现了这个功能。

    public Vector2Point5D getWorldLocation() {
        return worldLocation;
    }

    public void setWorldLocation(float x, float y, int z) {
        this.worldLocation = new Vector2Point5D();
        this.worldLocation.x = x;
        this.worldLocation.y = y;
        this.worldLocation.z = z;
    }

我们希望能够获取和设置我们之前已经讨论过的许多成员变量。这些获取器和设置器将实现这一功能。

    public void setBitmapName(String bitmapName){
        this.bitmapName = bitmapName;
    }

    public float getWidth() {
        return width;
    }

    public void setWidth(float width) {
        this.width = width;
    }

    public float getHeight() {
        return height;
    }

    public void setHeight(float height) {
        this.height = height;
    }

此外,我们还将希望能够检查和更改我们活动变量和可见变量的状态。

    public boolean isActive() {
        return active;
    }

    public boolean isVisible() {
        return visible;
    }

    public void setVisible(boolean visible) {
        this.visible = visible;
    }

设置和获取每个GameObjecttype

    public char getType() {
        return type;
    }

    public void setType(char type) {
        this.type = type;
    }

}// End of GameObject

现在,我们将从GameObject创建我们的第一个子类。在 Android Studio 资源管理器中右键点击包名,并创建一个名为Grass的类。这将是我们第一个基本的地砖类型,玩家可以在上面走动。

这段简单的代码使用构造函数来初始化高度、宽度、类型以及游戏世界中的位置。请注意,所有这些信息都是作为参数传递给构造函数的。Grass类唯一“知道”的,以及与其他简单的GameObject子类区别开来的少数几件事之一,就是bitmapName的值,在这个情况下是turf

如先前讨论的,我们还提供了一个空的update方法的实现:

public class Grass extends GameObject {

    Grass(float worldStartX, float worldStartY, char type) {
        final float HEIGHT = 1;
        final float WIDTH = 1;

        setHeight(HEIGHT); // 1 metre tall
        setWidth(WIDTH); // 1 metre wide

        setType(type);

        // Choose a Bitmap
        setBitmapName("turf");

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);
    }

    public void update(long fps, float gravity) {}
}

现在,将下载包中Chapter5/drawable文件夹里的turf.png图形添加到 Android Studio 的drawable文件夹中。

最后,我们将对我们的Player类进行一个最基础的实现,该类也将扩展GameObject。我们不会在这个类中放置任何功能,只需一个xy的世界位置。这样,我们接下来要实现的Viewport类就知道要聚焦在哪里了。

这是代表我们的英雄 Bob 的Player类。在这个阶段,这个类和Grass类一样简单直接,几乎与Grass类相同。随着我们进展,这将会有实质性的变化和发展。注意,我们将类型设置为p

import android.content.Context;

public class Player extends GameObject {

    Player(Context context, float worldStartX, 
        float worldStartY, int pixelsPerMetre) {

        final float HEIGHT = 2;
        final float WIDTH = 1;

        setHeight(HEIGHT); // 2 metre tall
        setWidth(WIDTH); // 1 metre wide

        setType('p');

        // Choose a Bitmap
        // This is a sprite sheet with multiple frames
        // of animation. So it will look silly until we animate it
        // In chapter 6.

        setBitmapName("player");

        // X and y locations from constructor parameters

        setWorldLocation(worldStartX, worldStartY, 0);

    }

    public void update(long fps, float gravity) {

    }
}

将下载包中drawable文件夹里的player.png图形添加到 Android Studio 的drawable文件夹中。这个图形是一个多帧的精灵表,所以在第六章平台游戏 – Bob, Beeps, 和 Bumps中进行动画处理之前,它不会很好地显示,但现在它可以作为一个占位符。

正如我们接下来将看到的,玩家看到的游戏世界的视图,将聚焦于 Bob,这应该是在你意料之中的。

通过视口看到的视图。

可以将视口视为跟随我们游戏动作的电影摄像机。它定义了要向玩家展示的游戏世界区域。通常,它会以 Bob 为中心。

它还通过确定哪些物体在玩家的视野内外,使我们的绘图方法更加高效。如果在一特定时刻它们并不相关,那么绘制或处理一堆敌人是毫无意义的。

通过实现第一阶段检测,即从需要检查碰撞的对象列表中移除屏幕外的对象,这将显著加快碰撞检测等任务的速度,而且这样做出奇地简单。

此外,我们的Viewport类将负责将游戏世界的坐标转换为屏幕上绘制的适当像素坐标。我们还将了解这个类是如何计算GameObject类在prepareBitmap方法中使用的pixelsPerMetre值的。

Viewport类确实是一个功能全面的东西。那么,让我们开始编程吧。

首先,我们将声明一大堆有用的变量。我们还有一个 Vector2Point5D,它将用于表示当前视口中焦点的世界上的任意点。然后,我们分别为 pixelsPerMetreXpixelsPerMetreY 分配了整数值。

注意

实际上,在这个实现中,pixelsPerMetrXpixelsPerMetreY 之间没有区别。但是,Viewport 类可以升级,以考虑基于屏幕尺寸而不是仅分辨率的不同设备宽高比。在这个实现中我们没有这样做。

接下来,我们简单地在两个轴上都有屏幕的分辨率:screenXResolutionscreenYResolution。然后我们有 screenCentreXscreenCentreY,它们基本上是前两个变量除以二以找到中间位置。

在我们声明的变量列表中,我们有 metresToShowXmetresToShowY,它们将是我们将压缩到视口中的米数。改变这些值将显示屏幕上更多或更少的游戏世界。

在这一点上,我们将声明的最后一个成员是 int numClipped。我们将使用它输出调试文本,以查看 Viewport 类在提高绘图、更新和多阶段碰撞检测的效率方面有何影响。

创建一个名为 Viewport 的新类,并声明我们刚刚讨论过的变量:

import android.graphics.Rect;

public class Viewport {
    private Vector2Point5D currentViewportWorldCentre;
    private Rect convertedRect;
    private int pixelsPerMetreX;
    private int pixelsPerMetreY;
    private int screenXResolution;
    private int screenYResolution;
    private int screenCentreX;
    private int screenCentreY;
    private int metresToShowX;
    private int metresToShowY;
    private int numClipped;

现在,让我们看看构造函数。构造函数只需要知道屏幕的分辨率。这是通过参数 xy 获取的,当然,我们分别将其分配给 screenXResolutionscreenYResolution

然后,如前所述,我们将这两个变量除以二,并将结果分别分配给 screenCentreXscreenCentreY

pixelsPerMetreXpixelsPerMetreY 是通过分别除以 32 和 18 来计算的,因此一个分辨率为 840 x 400 像素的设备将会有每米x/y的像素数为 32/22。现在,我们有变量表示当前设备上表示游戏世界一米的屏幕像素数量。在代码中我们会多次看到,这将非常有用。

我们实际上会绘制一个比这稍宽的区域,以确保屏幕边缘不会有难看的缝隙/线条,并将 34 分配给 metresToShowX,20 分配给 metresToShowY。现在,我们有变量表示我们每一帧将绘制多少游戏世界。

提示

一旦有了屏幕输出,你可以通过调整这些值来为玩家创建放大或缩小的体验。

在构造函数即将结束时,我们创建了一个名为 convertedRect 的新 Rect 对象,我们很快就会看到它的实际应用。我们在 currentViewportWorldCentre 上调用 new() 方法,所以它很快就能投入使用。

 Viewport(int x, int y){

        screenXResolution = x;
        screenYResolution = y;

        screenCentreX = screenXResolution / 2;
        screenCentreY = screenYResolution / 2;

        pixelsPerMetreX = screenXResolution / 32;
        pixelsPerMetreY = screenYResolution / 18;

        metresToShowX = 34;
        metresToShowY = 20;

        convertedRect = new Rect();
        currentViewportWorldCentre = new Vector2Point5D();

}

注意

如果这个项目中的某些截图看起来与您得到的结果略有不同,那是因为一些图片是使用不同的视口设置来突出游戏世界的不同方面。

我们为Viewport类编写的第一个方法是setWorldCentre()。它接收一个xy参数,并立即分配给currentWorldCentre。我们需要这个方法,因为玩家当然会在世界中移动,我们需要让Viewport类知道 Bob 的位置。同样,正如我们将在第八章,组合在一起中看到的,我们也会有不想让 Bob 成为关注焦点的情况。

void setWorldCentre(float x, float y){
  currentViewportWorldCentre.x  = x;
  currentViewportWorldCentre.y  = y;
}

现在,一些简单的获取器和设置器将在我们进行时非常有用。

public int getScreenWidth(){
  return  screenXResolution;
}

public int getScreenHeight(){
  return  screenYResolution;
}

public int getPixelsPerMetreX(){
  return  pixelsPerMetreX;
}

我们通过worldToScreen()方法实现了Viewport类的主要功能之一。顾名思义,这个方法是用来将当前可见视口中的所有对象的位置从世界坐标转换为可以实际绘制在屏幕上的像素坐标。它返回我们之前准备好的rectToDraw对象作为结果。

worldToScreen()方法就是这样工作的。它接收一个对象的xy世界位置以及该对象的宽度和高度。利用这些值,分别从当前屏幕的世界视口中心(xy)减去对象的世界坐标乘以每米的像素数。然后,对于对象的左和上坐标,从像素屏幕中心值中减去结果,对于下和右坐标,则加上。

这些值随后被包装进convertedRect的左、上、右和下值中,并返回给PlatformViewdraw方法。将worldToScreen方法添加到Viewport类中:


public Rect worldToScreen(
  float objectX, 
  float objectY, 
  float objectWidth, 
  float objectHeight){

   int left = (int) (screenCentreX -               
    ((currentViewportWorldCentre.x - objectX) 
    * pixelsPerMetreX));

    int top =  (int) (screenCentreY -         
    ((currentViewportWorldCentre.y - objectY) 
    * pixelsPerMetreY));

   int right = (int) (left + 
    (objectWidth * 
    pixelsPerMetreX));

  int bottom = (int) (top + 
    (objectHeight * 
    pixelsPerMetreY));

  convertedRect.set(left, top, right, bottom);

  return convertedRect;
}

现在,我们实现了Viewport类的第二个主要功能,即移除当前对我们没有兴趣的对象。我们称这个过程为剪辑,我们将要调用的方法是clipObjects()

再次,我们接收作为参数的物体的xywidthheight。测试首先假设我们想要剪辑当前对象,并将true分配给clipped

然后,四个嵌套的if语句测试对象的每一个点是否都在视口相关侧边的范围内。如果是,我们将clipped设置为false。我们设计的某些级别将包含超过一千个对象,但我们将会看到,在任何给定帧中,我们很少需要处理(更新、碰撞检测和绘制)超过四分之一的对象。输入clipObjects方法的代码:


public boolean clipObjects(float objectX, 
  float objectY, 
  float objectWidth, 
  float objectHeight) {

  boolean clipped = true;

   if (objectX - objectWidth < 
    currentViewportWorldCentre.x + (metresToShowX / 2)) {

    if (objectX + objectWidth > 
      currentViewportWorldCentre.x - (metresToShowX / 2)) {

      if (objectY - objectHeight <           
        currentViewportWorldCentre.y + 
        (metresToShowY / 2)) {

        if (objectY + objectHeight >       
          currentViewportWorldCentre.y - 
          (metresToShowY / 2)){

                 clipped = false;
        }     
      }

    }

  }

   // For debugging
   if(clipped){
       numClipped++;
   }

   return clipped;
}

现在,我们提供了对numClipped变量的访问权限,以便它可以每帧被读取并重置为零。

public int getNumClipped(){
  return numClipped;    
}

public void resetNumClipped(){
  numClipped = 0;
}

}// End of Viewport

让我们声明并初始化我们的Viewport对象。在PlatformView构造函数中初始化我们的Paint对象之后,添加以下代码。新代码在这里高亮显示:

  // Initialize our drawing objects
  ourHolder = getHolder();
  paint = new Paint();

 // Initialize the viewport
 vp = new Viewport(screenWidth, screenHeight);

}// End of constructor

现在,我们可以描述并定位游戏世界中的对象,并专注于我们感兴趣的世界精确部分。让我们看看我们实际上是如何将对象放入那个世界的,这样我们就可以像以前一样更新和绘制它们。我们还将探讨关卡的概念。

创建关卡

在这里,我们将了解如何构建我们的LevelManagerLevelData和我们第一个真正的关卡LevelCave

LevelManager类最终将需要我们InputController类的一个副本。因此,为了尽量遵循我们不需要删除任何代码的意图,我们将在LevelManager构造函数中包含一个InputController的参数。

让我们快速为我们的InputController类创建一个空白模板。按照通常的方式创建一个新类,并将其命名为InputController。添加以下代码:

public class InputController {
    InputController(int screenWidth, int screenHeight) {
    }
}

现在,让我们看看我们最初非常简单的LevelData类。创建一个新类,将其命名为LevelData,并添加此代码。在这个阶段,它仅包含一个用于StringsArrayList对象。

import java.util.ArrayList;

public class LevelData {
    ArrayList<String> tiles;

    // This class will evolve along with the project

    // Tile types
    // . = no tile
    // 1 = Grass

}

接下来,我们可以开始创建最终将成为我们第一个可玩关卡的代码。创建一个新类,将其命名为LevelCave,并添加此代码:

import java.util.ArrayList;

public class LevelCave extends LevelData{
    LevelCave() {
    tiles = new ArrayList<String>();
    this.tiles.add("p.............................................");
    this.tiles.add("..............................................");
    this.tiles.add(".....................111111...................");
    this.tiles.add("..............................................");
    this.tiles.add("............111111............................");
    this.tiles.add("..............................................");
    this.tiles.add(".........1111111..............................");
    this.tiles.add("..............................................");
    this.tiles.add("..............................................");
    this.tiles.add("..............................................");
    this.tiles.add("..............................11111111........");
    this.tiles.add("..............................................");
    }
}

提示

LevelCave文件中,p代表玩家的位置是任意的。只要它在里面,Player对象就会被初始化。玩家角色的实际生成位置由对loadLevel方法的调用决定,我们很快就会看到。我通常将代表玩家的p作为地图第一行第一个元素,这样就不太可能被遗忘。

现在,让我们谈谈这个关卡设计将如何工作。我们将在LevelCave类中的代码的tiles.add("..."部分输入字母数字字符。我们将根据要放入关卡的GameObject输入不同的字母数字字符。目前,我们只有一个p代表Player对象,一个1代表Grass对象,以及一个句点(.)代表一个游戏世界一平方米的空地。

提示

这意味着上一代码块中使用1字符定位Grass对象的方式可以完全按照你的喜好来安排。确实如此,每当我们查看LevelCave类的代码时,请随意即兴发挥和实验。

随着项目的进行,我们将添加超过二十个不同的GameObject子类。有些将像Grass一样是静止的,其他的将是具有思考能力的侵略性敌人。所有这些都可放置在我们的关卡设计中。

现在,我们可以实现一个类来管理我们的关卡。创建一个新的 Java 类,将其命名为LevelManager。随着我们逐步进行,输入LevelManager类的代码,一次讨论一个代码块。

首先,是一些导入指令。

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;
import java.util.ArrayList;

现在,构造函数是我们有一个String类型的level来保存关卡名称,mapWidthmapHeight以游戏世界米为单位存储当前关卡的宽度和高度,一个Player对象,因为我们知道我们总会有一个,以及一个名为playerIndexint类型。

不久,我们将拥有许多GameObject类的ArrayList对象,始终拥有Player对象的索引将非常方便。

接下来,我们有布尔值playing,因为我们需要知道游戏是在进行中还是暂停,以及一个名为gravity的浮点数。

提示

在这个项目的背景下,重力不会发挥其全部潜力,但可以轻松地操纵它,使不同级别的重力不同。这就是为什么它在LevelManager类中的原因。

最后,我们声明一个LevelData类型的对象,一个用于保存所有GameObject对象的ArrayList对象,一个用于保存玩家控制按钮表示的ArrayList对象,以及一个常规数组用于保存我们大部分需要的Bitmap对象。

public class LevelManager {

    private String level;
    int mapWidth;
    int mapHeight;

    Player player;
    int playerIndex;

    private boolean playing;
    float gravity;

    LevelData levelData;
    ArrayList<GameObject> gameObjects;

    ArrayList<Rect> currentButtons;
    Bitmap[] bitmapsArray;

然后,在构造函数中,我们检查签名并看到它接收一个Context对象,pixelsPerMetre(在Viewport类构造时确定),再次直接来自Viewport类的screenWidth,我们InputController类的一个副本,以及要加载的关卡名称。int参数pxpy是玩家的起始坐标。

我们将关卡参数赋值给我们的成员级别,然后切换以确定哪个类将是我们的当前关卡。当然,目前我们只有一个LevelCave

然后,我们初始化我们的gameObject ArrayListbitmapsArray。然后我们调用loadMapData(),这是我们很快会编写的一个方法。在此之后,我们将playing设置为true,最后我们有一个获取器方法来找出playing的状态。在LevelManager类中输入我们刚刚讨论的代码:

public LevelManager(Context context, 
    int pixelsPerMetre, int screenWidth, 
    InputController ic, 
    String level, 
    float px, float py) {

    this.level = level;

    switch (level) {
        case "LevelCave":
        levelData = new LevelCave();
        break;

        // We can add extra levels here

    }

    // To hold all our GameObjects
    gameObjects = new ArrayList<>();

    // To hold 1 of every Bitmap
    bitmapsArray = new Bitmap[25];

    // Load all the GameObjects and Bitmaps
    loadMapData(context, pixelsPerMetre, px, py);

    // Ready to play
    playing = true;
}

public boolean isPlaying() {
    return playing;
}

现在,我们有一个非常简单的方法,可以基于我们当前处理的GameObject类型获取任何Bitmap对象。这样,每个GameObject不必持有自己的Bitmap对象。例如,我们可以设计一个包含数百个Grass对象的关卡。这很容易就会用尽即使是现代平板电脑的内存。

我们的getBitmap方法接收一个int类型的索引值,并返回一个Bitmap对象。我们将在下一个方法中看到如何访问index的适当值:

    // Each index Corresponds to a bitmap
    public Bitmap getBitmap(char blockType) {

        int index;
        switch (blockType) {
            case '.':
                index = 0;
                break;

            case '1':
                index = 1;
                break;

            case 'p':
                index = 2;
                break;

            default:
                index = 0;
                break;
        }// End switch

        return bitmapsArray[index];

 }// End getBitmap

下一个方法将使我们能够获得调用getBitmap方法的index。只要char案例与我们创建的各种GameObject子类持有的type值相对应,并且此方法返回的索引与bitmapsArray中适当Bitmap的索引相匹配,我们就只需要每个Bitmap对象的一个副本。

// This method allows each GameObject which 'knows'
// its type to get the correct index to its Bitmap
// in the Bitmap array.
public int getBitmapIndex(char blockType) {

    int index;
        switch (blockType) {
            case '.':
                index = 0;
                break;

            case '1':
                index = 1;
                break;

            case 'p':
                index = 2;
                break;

            default:
                index = 0;
                break;

        }// End switch

        return index;
    }// End getBitmapIndex()

现在,我们使用LevelManager类进行实际的工作,并从我们的设计中加载关卡。该方法需要pixelsPerMetrePlayer对象的坐标才能执行其工作。由于这是一个大方法,解释和代码已经被分成几个部分。

在这一部分,我们简单声明一个名为indexint类型,并将其设置为-1。当我们遍历我们的关卡设计时,它将帮助我们跟踪当前的位置。

然后,我们使用ArrayList的大小和ArrayList的第一个元素的长度分别计算地图的高度和宽度。

// For now we just load all the grass tiles
// and the player. Soon we will have many GameObjects
private void loadMapData(Context context, 
  int pixelsPerMetre, 
  float px, float py) {

   char c;

   //Keep track of where we load our game objects
   int currentIndex = -1;

   // how wide and high is the map? Viewport needs to know
   mapHeight = levelData.tiles.size();
   mapWidth = levelData.tiles.get(0).length();

我们从ArrayList对象的第一个字符串的第一个元素开始进入嵌套的for循环。我们在移动到第二个字符串之前,从左到右遍历第一个字符串。

我们检查当前位置是否除了空格(.)之外还有其他对象,如果有,我们就进入一个开关块,在指定位置创建适当的对象。

如果我们遇到一个1,那么我们向ArrayList中添加一个新的Grass对象;如果遇到一个p,我们就在传递到LevelManager类构造函数的位置初始化Player对象。当一个新Player对象被创建时,我们还会初始化我们的playerIndexplayer对象,以备将来使用。

for (int i = 0; i < levelData.tiles.size(); i++) {
            for (int j = 0; j < 
                    levelData.tiles.get(i).length(); j++) {

                c = levelData.tiles.get(i).charAt(j);

                    // Don't want to load the empty spaces
                    if (c != '.'){ 
                      currentIndex++;
                      switch (c) {

                        case '1':
                            // Add grass to the gameObjects
                            gameObjects.add(new Grass(j, i, c));
                            break;

                        case 'p':
                            // Add a player to the gameObjects
                            gameObjects.add(new Player
                                (context, px, py, 
                                 pixelsPerMetre));

                            // We want the index of the player
                            playerIndex = currentIndex;
                            // We want a reference to the player
                            player = (Player)           
                            gameObjects.get(playerIndex);

                            break;

            }// End switch

如果一个新的对象被添加到gameObjects ArrayList中,我们需要检查相应的位图是否已经被添加到bitmapsArray中。如果没有,我们使用当前考虑的GameObject类的prepareBitmap方法添加一个。以下是执行此检查并在必要时准备位图的代码:

// If the bitmap isn't prepared yet
if (bitmapsArray[getBitmapIndex(c)] == null) {

    // Prepare it now and put it in the bitmapsArrayList
    bitmapsArray[getBitmapIndex(c)] =
        gameObjects.get(currentIndex).
        prepareBitmap(context,                                                
        gameObjects.get(currentIndex).                                                        
        getBitmapName(),                                     
        pixelsPerMetre);

}// End if

}// End if (c != '.'){ 

}// End for j

}// End for i

}// End loadMapData()

}// End LevelManager

回到PlatformView类中,为了使用我们的所有关卡对象,我们在PlatformView构造函数中初始化Viewport类之后立即调用loadLevel()。新代码已经突出显示,并提供现有代码作为上下文:

  // Initialize the viewport
  vp = new Viewport(screenWidth, screenHeight);

 // Load the first level
 loadLevel("LevelCave", 15, 2);

}

当然,现在我们需要在PlatformView类中实现loadLevel方法。

loadLevel方法需要知道要加载哪个关卡,这样LevelManager构造函数中的switch语句才能执行其工作,它还需要坐标来生成我们的英雄 Bob。

我们通过从vp获取的视口数据以及我们刚刚讨论的关卡/玩家数据调用其构造函数来初始化我们的LevelManager对象。

我们接着创建一个新的InputController类,同样从vp中传递一些数据。在第六章,Bob, Beeps, 和 Bumps中构建我们的InputController类时,我们会确切地看到如何使用这些数据。最后,我们调用vp.setWorldCentre(),并将玩家的位置坐标传递给它,这样屏幕就居中了 Bob。

public void loadLevel(String level, float px, float py) {

    lm = null;

    // Create a new LevelManager
    // Pass in a Context, screen details, level name 
    // and player location
    lm = new LevelManager(context, 
        vp.getPixelsPerMetreX(), 
        vp.getScreenWidth(), 
        ic, level, px, py);

    ic = new InputController(vp.getScreenWidth(),       
        vp.getScreenHeight());

    // Set the players location as the world centre     
    vp.setWorldCentre(lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().x,
        lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().y);
    }

我们可以在我们的update方法中添加一些代码,这将首先利用我们新的Viewport类的主要功能。

增强的更新方法

最后,我们可以使用我们的ArrayList游戏对象和Viewport功能来完善我们的增强型update方法。在下面的代码中,我们仅使用增强的for循环遍历每个GameObject。我们检查它是否isActive(),然后通过if语句将对象的位置和尺寸传递给clipObjects()。如果clipObjects()返回false,则对象没有被剪辑,并通过调用go.setVisible(true)将对象标记为可见。否则,通过调用go.setVisible(false)将其标记为不可见。这是此刻更新任何对象的唯一方面。我们将在本章末尾运行游戏时看到,它已经很有用了。在update方法中输入新代码:

for (GameObject go : lm.gameObjects) {
        if (go.isActive()) {
            // Clip anything off-screen
            if (!vp.clipObjects(go.getWorldLocation().x,                                
                go.getWorldLocation().y, 
                go.getWidth(), 
                go.getHeight())) {

                // Set visible flag to true
                go.setVisible(true);

            } else {
                // Set visible flag to false
                go.setVisible(false);
                // Now draw() can ignore them

            }
        }

    }
}

增强的绘制方法

现在,我们可以更精确地确定我们需要绘制哪些对象。首先,我们声明并初始化一个新的名为toScreen2dRect对象。

然后,我们从最低层开始,针对每一层遍历一次gameObjects ArrayList。在这个阶段,这并不是严格必要的,因为我们的所有对象默认都当前在零层。在项目结束前,我们将添加位于-1 层和 1 层的对象,如果我们能够避免,则不想重写代码。

接下来,我们检查对象是否可见并且是否在当前层。如果是,我们将当前对象的位置和尺寸传递给worldToScreen方法,该方法将结果返回给我们之前准备的toScreen2d Rect对象。然后,我们使用bitmapArray调用drawBitmap()以提供适当的位图,并传入toScreen2d的坐标。更新突出显示的draw方法:

private void draw() {

    if (ourHolder.getSurface().isValid()) {
        //First we lock the area of memory we will be drawing to
        canvas = ourHolder.lockCanvas();

        // Rub out the last frame with arbitrary color
        paint.setColor(Color.argb(255, 0, 0, 255));
        canvas.drawColor(Color.argb(255, 0, 0, 255));
 // Draw all the GameObjects
 Rect toScreen2d = new Rect();

 // Draw a layer at a time
 for (int layer = -1; layer <= 1; layer++){
 for (GameObject go : lm.gameObjects) {
 //Only draw if visible and this layer
 if (go.isVisible() && go.getWorldLocation().z 
 == layer) { 

 toScreen2d.set(vp.worldToScreen
 (go.getWorldLocation().x,
 go.getWorldLocation().y,
 go.getWidth(),
 go.getHeight()));

 // Draw the appropriate bitmap
 canvas.drawBitmap(
 lm.bitmapsArray
 [lm.getBitmapIndex(go.getType())],
 toScreen2d.left,
 toScreen2d.top, paint);
 }
 }
}

现在,仍然在draw方法中,我们将调试信息打印到屏幕上,包括我们的gameObjects ArrayList的大小与这一帧中被剪辑的对象数量比较。

然后,我们通过常规调用unlockCanvasAndPost()来完成draw方法。注意,在if(debugging)块的末尾,我们调用vp.resetNumClippednumClipped变量重置为零,为下一帧做准备。在draw方法中的上一代码块之后直接添加此代码:

// Text for debugging
if (debugging) {
 paint.setTextSize(16);
 paint.setTextAlign(Paint.Align.LEFT);
 paint.setColor(Color.argb(255, 255, 255, 255));
 canvas.drawText("fps:" + fps, 10, 60, paint);

 canvas.drawText("num objects:" + 
 lm.gameObjects.size(), 10, 80, paint);

 canvas.drawText("num clipped:" + 
 vp.getNumClipped(), 10, 100, paint);

 canvas.drawText("playerX:" + 
 lm.gameObjects.get(lm.playerIndex).
 getWorldLocation().x,
 10, 120, paint);

 canvas.drawText("playerY:" + 
 lm.gameObjects.get(lm.playerIndex).
 getWorldLocation().y, 
 10, 140, paint);

 //for reset the number of clipped objects each frame
 vp.resetNumClipped();

}// End if(debugging)

// Unlock and draw the scene
ourHolder.unlockCanvasAndPost(canvas);

}// End (ourHolder.getSurface().isValid())
}// End draw()

在这个项目中,我们第一次实际运行游戏并看到了一些结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意图像中我们LevelCave设计中草地的精确布局。您还可以看到我们压缩的 Bob 精灵表和有 28 个对象,但其中 10 个已被剪辑。随着我们的关卡变得越来越大,剪辑与未剪辑的比例将大幅增加,绝大多数对象将被剪辑。

总结

在本章中,我们已经介绍了许多内容,现在拥有了一个完善的游戏引擎。

由于我们已经完成了大部分设置工作,从现在开始,我们添加的大部分代码也将有可见(或可听)的结果,并将更加令人满意,因为我们将能够定期运行我们的游戏以查看改进。

在下一章中,我们将添加声音效果和输入检测,从而让 Bob 栩栩如生。然后,我们将会看到他的世界可能多么危险,并将迅速添加碰撞检测,使他能够站在平台上。

第六章:平台游戏 - Bob、哔哔声和碰撞

我们的 basic 游戏引擎设置好后,我们就可以开始快速进展了。在本章中,我们将快速添加一个SoundManager类,我们可以在任何需要的时候用它来发出声音。之后,我们将为 Bob 添加一些实质性的内容,并在Player类中实现我们所需的核心功能。然后,我们可以处理多阶段碰撞检测的第二阶段(剪辑后),让 Bob 具备站在平台上的有用技能。

在我们完成这项重大任务后,我们将通过实现InputController类将 Bob 的控制权交给玩家。Bob 终于能够到处跑和跳了。在本章结束时,我们将为 Bob 的精灵表制作动画,让他看起来真的在跑,而不是到处滑动。

SoundManager 类

在接下来的几章中,我们将为各种事件添加声音效果。有时这些声音将直接在主PlatformView类中触发,但其他时候,它们需要在代码更远的角落中触发,比如InputController类,甚至是在GameObject类内部。我们将快速制作一个简单的SoundManager类,当需要哔哔声时,可以传递并按需使用。

创建一个新的 Java 类,将其命名为SoundManager。这个类有三个主要部分。在第一部分,我们简单地声明一个SoundPool对象和一些int变量,以保存每个声音效果的引用。以下是第一部分代码,声明和成员:

import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.media.AudioManager;
import android.media.SoundPool;
import android.util.Log;

import java.io.IOException;

public class SoundManager {
    private SoundPool soundPool;
    int shoot = -1;
    int jump = -1;
    int teleport = -1;
    int coin_pickup = -1;
    int gun_upgrade = -1;
    int player_burn = -1;
    int ricochet = -1;
    int hit_guard = -1;
    int explode = -1;
    int extra_life = -1;

类的第二部分是loadSound方法,它毫不意外地将所有声音加载到内存中,准备播放。我们在PlatformView构造函数中初始化一个SoundManager对象后,将调用这个方法。接下来输入这段代码:

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("jump.ogg");
        jump = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("teleport.ogg");
        teleport = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("coin_pickup.ogg");
        coin_pickup = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("gun_upgrade.ogg");
        gun_upgrade = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("player_burn.ogg");
        player_burn = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("ricochet.ogg");
        ricochet = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("hit_guard.ogg");
        hit_guard = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("explode.ogg");
        explode = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("extra_life.ogg");
        extra_life = soundPool.load(descriptor, 0);

    }catch(IOException e){
        //Print an error message to the console
        Log.e("error", "failed to load sound files");

    }

}

最后,对于我们的SoundManager类,我们需要能够播放任何我们喜欢的声音。这个playSound方法只是简单地通过一个作为参数传递的字符串来切换。当我们有一个SoundManager对象时,我们可以通过一个合适的字符串参数简单地调用playSound()

public void playSound(String sound){
        switch (sound){
            case "shoot":
                soundPool.play(shoot, 1, 1, 0, 0, 1);
                break;

            case "jump":
                soundPool.play(jump, 1, 1, 0, 0, 1);
                break;

            case "teleport":
                soundPool.play(teleport, 1, 1, 0, 0, 1);
                break;

            case "coin_pickup":
                soundPool.play(coin_pickup, 1, 1, 0, 0, 1);
                break;

            case "gun_upgrade":
                soundPool.play(gun_upgrade, 1, 1, 0, 0, 1);
                break;

            case "player_burn":
                soundPool.play(player_burn, 1, 1, 0, 0, 1);
                break;

            case "ricochet":
                soundPool.play(ricochet, 1, 1, 0, 0, 1);
                break;

            case "hit_guard":
                soundPool.play(hit_guard, 1, 1, 0, 0, 1);
                break;

            case "explode":
                soundPool.play(explode, 1, 1, 0, 0, 1);
                break;

            case "extra_life":
                soundPool.play(extra_life, 1, 1, 0, 0, 1);
                break;

        }

    }
}// End SoundManager

在上一章你的新游戏引擎类之后,PlatformView类声明后声明一个类型为SoundManager的新对象。

// Our new engine classes
private LevelManager lm;
private Viewport vp;
InputController ic;
SoundManager sm;

接下来,在PlatformView构造函数中初始化SoundManager对象,并调用loadSound(),如下所示:

// Initialize the viewport
vp = new Viewport(screenWidth, screenHeight);

sm = new SoundManager();
sm.loadSound(context);

loadLevel("LevelCave", 15, 2);

你可以使用 BFXR 创建所有自己的声音,或者直接从Chapter6/assets文件夹复制我的。将所有声音复制到你的 Android Studio 项目的assets文件夹中。如果还不存在,请在项目的src/main文件夹中创建一个assets文件夹以实现这一点。

现在,我们可以在任何地方播放声音效果。是时候让我们的英雄 Bob 活灵活现了。

介绍 Bob

在这里,我们可以为你的Player类增加一些实质性的内容。不过,这不会是我们最后一次回顾Player类。现在,我们将添加必要的功能,让 Bob 能够移动。完成这一步后,我们将会添加代码,允许玩家使用即将到来的碰撞检测代码和Animation类。

首先,我们需要向Player类中添加一些成员。Player类需要知道它能移动多快,玩家何时按下左右控制键,以及它是否在掉落或跳跃。此外,Player类还需要知道它已经跳跃了多长时间,以及它应该跳跃多久。

下一个代码块为我们提供了监控所有这些事物的变量。我们很快就会看到,如何使用它们让 Bob 做出我们想要的行为。

现在,我们知道这些变量是干什么用的了。我们可以在类声明后直接添加这段代码,如下所示:

public class Player extends GameObject {

 final float MAX_X_VELOCITY = 10;
 boolean isPressingRight = false;
 boolean isPressingLeft = false;

 public boolean isFalling;
 private boolean isJumping;
 private long jumpTime;
 private long maxJumpTime = 700;// jump 7 10ths of second

此外,还有一些其他与移动相关的条件我们需要跟踪,但它们在其他类中也会很有用。因此,我们将它们作为成员添加到GameObject类中。我们将跟踪当前的水平速度和垂直速度,对象面向的方向,以及以下变量来确定对象是否可以移动。

private float xVelocity;
private float yVelocity;
final int LEFT = -1;
final int RIGHT = 1;
private int facing;
private boolean moves = false;

现在,在GameObject类中,我们将添加一个move方法。这个方法简单检查下 x 轴或 y 轴上的速度是否为零,如果不是,它就会通过改变对象的worldLocation来移动对象。这个方法使用速度(xVelocityyVelocity)除以当前的每秒帧数来计算每帧移动的距离。这样可以确保无论当前的每秒帧数是多少,移动都是完全正确的。无论我们的游戏运行是否流畅,或者有所波动,或者安卓设备中的 CPU 性能强大与否,都没有关系。我们很快就会在Player类的update方法中调用这个move方法。在项目的后期,我们也会从其他类中调用它。

void move(long fps){
        if(xVelocity != 0) {
            this.worldLocation.x += xVelocity / fps;
        }

        if(yVelocity != 0) {
            this.worldLocation.y += yVelocity / fps;
        }
    }

接下来,在GameObject类中,我们为之前添加的新变量准备了一堆 getter 和 setter 方法。唯一需要注意的是,两个速度变量(setxVelocitysetyVelocity)的 setter 在真正赋值之前会检查if(moves)

public int getFacing() {
  return facing;
}

public void setFacing(int facing) {
  this.facing = facing;
}

public float getxVelocity() {
  return xVelocity;
}

public void setxVelocity(float xVelocity) {
  // Only allow for objects that can move
  if(moves) {
    this.xVelocity = xVelocity;
  }
}

public float getyVelocity() {
  return yVelocity;
}

public void setyVelocity(float yVelocity) {
  // Only allow for objects that can move
  if(moves) {
    this.yVelocity = yVelocity;
  }
}

public boolean isMoves() {
  return moves;
}

public void setMoves(boolean moves) {
  this.moves = moves;
}

public void setActive(boolean active) {
  this.active = active;
}

现在,回到Player类的构造函数中,我们可以使用其中一些新方法在对象创建时进行设置。在Player构造函数中添加高亮显示的代码。

setHeight(HEIGHT); // 2 metre tall
setWidth(WIDTH); // 1 metre wide

// Standing still to start with
setxVelocity(0);
setyVelocity(0);
setFacing(LEFT);
isFalling = false;

// Now for the player's other attributes
// Our game engine will use these
setMoves(true);
setActive(true);
setVisible(true);
//...

最后,我们可以在Player类的update方法中实际使用所有这些新代码。

首先,我们处理当isPressingRightisPressingLeft为真时会发生什么。当然,我们还需要能够通过屏幕触摸来设置这些变量。很简单,下一个代码块如果isPressingRight为真,将水平速度设置为MAX_X_VELOCITY;如果isPressingLeft为真,则设置为-MAX_X_VELOCITY。如果都不为真,则将水平速度设置为零,即静止不动。

public void update(long fps, float gravity) {
 if (isPressingRight) {
 this.setxVelocity(MAX_X_VELOCITY);
 } else if (isPressingLeft) {
 this.setxVelocity(-MAX_X_VELOCITY);
 } else {
 this.setxVelocity(0);
 }

接下来,我们检查玩家移动的方向,并调用setFacing(),参数为RIGHTLEFT

//which way is player facing?
if (this.getxVelocity() > 0) {
  //facing right
  setFacing(RIGHT);
} else if (this.getxVelocity() < 0) {
  //facing left
  setFacing(LEFT);
}//if 0 then unchanged

现在,我们可以处理跳跃。当玩家按下跳跃按钮时,如果成功,isJumping将被设置为真,jumpTime将被设置为当前系统时间。这样,我们就可以在每一帧进入if(isJumping)块,测试鲍勃已经跳跃了多长时间,并且如果他没有超过maxJumpTime,就会采取两个可能动作之一。

动作一是:如果我们还没有跳到一半,y速度设置为-gravity(向上)。动作二是:如果鲍勃跳过一半了,他的y速度设置为gravity(向下)。

当超过maxJumpTime时,isJumping会被重新设置为假,直到下一次玩家点击跳跃按钮。以下代码中的最后一个else子句在isJumping为假时执行,并将玩家的y速度设置为gravity。注意,还有一行代码将isFalling设置为true。正如我们将要看到的,这个变量用于控制玩家初次尝试跳跃时以及我们碰撞检测代码部分会发生什么。它基本上阻止了玩家在空中跳跃。

// Jumping and gravity
if (isJumping) {
  long timeJumping = System.currentTimeMillis() - jumpTime;
  if (timeJumping < maxJumpTime) {
    if (timeJumping < maxJumpTime / 2) {
      this.setyVelocity(-gravity);//on the way up
       } else if (timeJumping > maxJumpTime / 2) {
          this.setyVelocity(gravity);//going down
       }
  } else {
    isJumping = false;
  }
} else {
      this.setyVelocity(gravity);
      // Read Me!
      // Remove this next line to make the game easier
      // it means the long jumps are less punishing
      // because the player can take off just after the platform
      // They will also be able to cheat by jumping in thin air
      isFalling = true;
}

在处理完跳跃之后,我们立即调用move()来更新xy坐标,如果它们有变化的话。

 // Let's go!
 this.move(fps);
}// end update()

这有点复杂,但除了实际控制之外,它几乎包含了我们让玩家移动所需的一切。我们只需要从我们PlatformView类的update方法中每一帧调用一次update()方法,我们的玩家角色就会活跃起来。

PlatformView类的update方法中,像这样添加以下高亮代码:

// Set visible flag to true
go.setVisible(true);

if (lm.isPlaying()) {
 // Run any un-clipped updates
 go.update(fps, lm.gravity);
}

} else {
  // Set visible flag to false
  //...

接下来,我们可以看到正在发生什么。在PlatformView类的draw方法的if(debugging)块中添加一些更多的文本输出。像这里显示的那样添加新的高亮代码:

canvas.drawText("playerY:" +   lm.gameObjects.get(lm.playerIndex).getWorldLocation().y,
  10, 140, paint);

canvas.drawText("Gravity:" + 
 lm.gravity, 10, 160, paint);

canvas.drawText("X velocity:" +   lm.gameObjects.get(lm.playerIndex).getxVelocity(), 
 10, 180, paint);

canvas.drawText("Y velocity:" +   lm.gameObjects.get(lm.playerIndex).getyVelocity(), 
 10, 200, paint);

//for reset the number of clipped objects each frame

现在为何不运行游戏呢?你可能已经注意到下一个问题是玩家不见了。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这是因为我们现在有了重力,而且调用update()的线程在应用程序启动时立即运行,甚至在我们完成关卡和玩家角色的设置之前。

我们需要做两件事。首先,我们只想在LevelManager类完成工作后运行update()。其次,我们需要在每一帧更新Viewport类的焦点,这样即使玩家正在掉入死亡(他经常这样做),屏幕也会以他为中心,这样我们就可以看到他的终结。

让我们从暂停模式开始游戏,这样玩家就不会错过。首先,我们将在LevelManager类中添加一个方法,该方法将切换游戏状态在玩与不玩之间。一个合适的名字可能是switchPlayingStatus()。按照如下所示,将新方法添加到LevelManager中:

public void switchPlayingStatus() {
        playing = !playing;
        if (playing) {
            gravity = 6;
        } else {
            gravity = 0;
        }
    }

现在,删除或注释掉LevelManager构造函数中设置playingtrue的那行代码。很快,这将会通过屏幕触摸和我们刚刚编写的方法来处理:

// Load all the GameObjects and Bitmaps
loadMapData(context, pixelsPerMetre, px, py);

//playing = true;
//..

我们将编写一点临时代码,只是一点点。我们已经知道,我们最终会将监控玩家输入的责任委托给我们的新InputController类。在重写的onTouchEvent方法中,这点代码是值得的,因为我们可以立即使用暂停功能。

这段代码将在每次触摸屏幕时使用我们刚刚编写的方法切换游戏状态。将重写的方法添加到PlatformView类中。我们将在本章稍后替换其中一些代码。

@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
  switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
    case MotionEvent.ACTION_DOWN:
         lm.switchPlayingStatus();
         break;
   }
return true;
}

你可以在Player类中将isPressingRight设置为true,然后运行游戏并点击屏幕。然后我们会看到玩家像幽灵一样从底部掉落,同时向屏幕右侧移动:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,让我们每帧更新视口,使其保持在玩家中心。将这段高亮代码添加到PlatformView类中的update方法的最后:

if (lm.isPlaying()) {
    //Reset the players location as the centre of the viewport
    vp.setWorldCentre(lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().x,
        lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().y);}
}// End of update()

如果你现在运行游戏,尽管玩家仍然向右掉入厄运,但至少屏幕会聚焦在他身上,让我们看到这一过程。

我们将处理持续下落的问题。

多阶段碰撞检测

我们已经看到,我们的玩家角色会简单地穿过世界,落入虚无。当然,我们需要玩家能够站在平台上。以下是我们要采取的措施。

我们将为每个重要的对象提供一个碰撞箱,这样我们就可以在Player类中提供测试碰撞箱是否与玩家接触的方法。每帧一次,我们将发送所有未被视口剪辑的碰撞箱到这个新方法,在这里可以测试是否发生碰撞。

我们这样做有两个主要原因。首先,通过仅发送未剪辑的碰撞箱进行碰撞测试,我们大大减少了检查的数量,如第三章,Tappy Defender – Taking Flight中的“碰撞检测”部分所述。其次,通过在Player类中处理检查,我们可以给玩家多个不同的碰撞箱,并根据哪个被击中稍微有不同的反应。

让我们创建一个自己的碰撞箱类,这样我们可以按照自己的需求来定义它。它需要使用浮点坐标,还需要一个intersects方法和一些获取器和设置器。创建一个新类,将其命名为RectHitbox

在这里,我们看到RectHitbox仅有一系列的自我解释的获取器和设置器。它还具有intersects方法,如果传递给它的RectHitbox与自身相交,则返回true。关于intersects()代码如何工作的解释,请参见第三章,Tappy Defender – Taking Flight。在新的类中输入以下代码:

public class RectHitbox {
    float top;
    float left;
    float bottom;
    float right;
    float height;

    boolean intersects(RectHitbox rectHitbox){
        boolean hit = false;

        if(this.right > rectHitbox.left
                && this.left < rectHitbox.right ){
            // Intersecting on x axis

            if(this.top < rectHitbox.bottom
                    && this.bottom > rectHitbox.top ){
                // Intersecting on y as well
                // Collision
                hit = true;
            }
        }

        return hit;
    }

    public void setTop(float top) {
        this.top = top;
    }

    public float getLeft() {
        return left;
    }

    public void setLeft(float left) {
        this.left = left;
    }

    public void setBottom(float bottom) {
        this.bottom = bottom;
    }

    public float getRight() {
        return right;
    }

    public void setRight(float right) {
        this.right = right;
    }

    public float getHeight() {
        return height;
    }

    public void setHeight(float height) {
        this.height = height;
    }
}

现在,我们可以将RectHitbox类作为GameObject的一个成员添加。在类声明后直接添加它。

private RectHitbox rectHitbox = new RectHitbox();

然后,我们添加一个方法来初始化碰撞箱,以及一个方法,以便在我们需要时获取它的副本。将这些两个方法添加到GameObject中:

public void setRectHitbox() {
   rectHitbox.setTop(worldLocation.y);
   rectHitbox.setLeft(worldLocation.x);
   rectHitbox.setBottom(worldLocation.y + height);
   rectHitbox.setRight(worldLocation.x + width);
}

RectHitbox getHitbox(){
  return rectHitbox;
}

现在,对于我们的Grass对象,我们添加一个对setRectHitbox()的调用,然后我们就可以开始与之碰撞了。在Grass类的构造函数的最后,添加这一行高亮代码。调用setRectHitbox()需要在setWorldLocation()之后进行,否则碰撞箱将不会围绕草地块。

// Where does the tile start
// X and y locations from constructor parameters
setWorldLocation(worldStartX, worldStartY, 0);
setRectHitbox();
}// End of Grass constructor

在我们开始理解进行碰撞检测的代码之前,需要让Player类拥有自己的碰撞箱集合。我们需要了解以下关于玩家角色的信息:

  • 当头部撞到它上方的物体时

  • 当脚部落在下方的平台上时

  • 当玩家从两侧走进某物时

为此,我们将创建四个碰撞箱;一个用于头部,一个用于脚部,还有两个用于左右两侧。由于它们是玩家独有的,我们将在Player类中创建碰撞箱。

Player类声明后立即声明四个碰撞箱作为成员:

RectHitbox rectHitboxFeet;
RectHitbox rectHitboxHead;
RectHitbox rectHitboxLeft;
RectHitbox rectHitboxRight;

在构造函数中,我们调用新的RectHitbox()来准备它们。注意我们还没有给碰撞箱赋值。我们很快就会看到如何操作。在Player构造函数的最后,像这样添加四个对new()的调用:

rectHitboxFeet = new RectHitbox();
rectHitboxHead = new RectHitbox();
rectHitboxLeft = new RectHitbox();
rectHitboxRight = new RectHitbox();

我们将看到如何正确初始化它们。下面代码中的碰撞箱值是基于实际角色形状在表示每个角色帧的矩形中所占空间手动估算的。如果你使用不同的角色图形,你可能需要调整你使用的精确值。

图表显示了每个碰撞箱将定位的大致图形表示位置。左侧和右侧碰撞箱看起来距离较远,是因为动画的不同帧比这一帧稍微宽一些。这是一个折中方案。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

代码必须在Player类中的update方法内调用move()之后的位置。这样,每次玩家位置改变时都会更新碰撞箱。在显示的确切位置添加高亮代码,这样我们就更接近能够开始碰撞到各种东西了。

// Let's go!
this.move(fps);

// Update all the hitboxes to the new location
// Get the current world location of the player
// and save them as local variables we will use next
Vector2Point5D location = getWorldLocation();
float lx = location.x;
float ly = location.y;

//update the player feet hitbox
rectHitboxFeet.top = ly + getHeight() * .95f;
rectHitboxFeet.left = lx + getWidth() * .2f;
rectHitboxFeet.bottom = ly + getHeight() * .98f;
rectHitboxFeet.right = lx + getWidth() * .8f;

// Update player head hitbox
rectHitboxHead.top = ly;
rectHitboxHead.left = lx + getWidth() * .4f;
rectHitboxHead.bottom = ly + getHeight() * .2f;
rectHitboxHead.right = lx + getWidth() * .6f;

// Update player left hitbox
rectHitboxLeft.top = ly + getHeight() * .2f;
rectHitboxLeft.left = lx + getWidth() * .2f;
rectHitboxLeft.bottom = ly + getHeight() * .8f;
rectHitboxLeft.right = lx + getWidth() * .3f;

// Update player right hitbox
rectHitboxRight.top = ly + getHeight() * .2f;
rectHitboxRight.left = lx + getWidth() * .8f;
rectHitboxRight.bottom = ly + getHeight() * .8f;
rectHitboxRight.right = lx + getWidth() * .7f;

}// End update()

在下一阶段,我们可以检测到一些碰撞并对它们做出反应。仅涉及玩家的碰撞,比如跌落、撞头或者试图穿墙,都将在下一个方法中直接处理,该方法位于Player类中。请注意,该方法还返回一个int值来表示是否发生碰撞以及碰撞发生在玩家的哪个部位,以便处理与其他物体(如拾取物或火坑)的碰撞。

新的checkCollisions方法接收一个RectHitbox作为参数。这将是我们当前正在检查碰撞的任何对象的RectHitbox。将checkCollisions方法添加到Player类中。

public int checkCollisions(RectHitbox rectHitbox) {
    int collided = 0;// No collision

    // The left
    if (this.rectHitboxLeft.intersects(rectHitbox)) {
        // Left has collided
        // Move player just to right of current hitbox
        this.setWorldLocationX(rectHitbox.right - getWidth() * .2f);
        collided = 1;
    }

    // The right
    if (this.rectHitboxRight.intersects(rectHitbox)) {
        // Right has collided
        // Move player just to left of current hitbox
        this.setWorldLocationX(rectHitbox.left - getWidth() * .8f);
        collided = 1;
    }

    // The feet
    if (this.rectHitboxFeet.intersects(rectHitbox)) {
        // Feet have collided
        // Move feet to just above current hitbox
        this.setWorldLocationY(rectHitbox.top - getHeight());
        collided = 2;
    }

    // Now the head
    if (this.rectHitboxHead.intersects(rectHitbox)) {
        // Head has collided. Ouch!
        // Move head to just below current hitbox bottom
        this.setWorldLocationY(rectHitbox.bottom);
        collided = 3;
    }

    return collided;
}

如前述代码所示,我们需要向GameObject类中添加一些 setter 方法,以便在检测到碰撞时可以更改xy世界坐标。向GameObject类添加以下两个方法:

public void setWorldLocationY(float y) {
  this.worldLocation.y = y;
}

public void setWorldLocationX(float x) {
  this.worldLocation.x = x;
}

最后一步是选择所有相关对象并进行碰撞测试。我们在PlatformView类的update方法中进行这项操作,然后根据哪个身体部位与哪种对象类型发生碰撞来进一步采取行动。由于我们只有一个可能与草地平台发生碰撞的对象类型,因此我们的 switch 块最初只会有一个默认情况。请注意,当检测到脚部发生碰撞时,我们将isFalling变量设置为false,使玩家能够跳跃。在显示的位置输入高亮代码:

// Set visible flag to true
go.setVisible(true);

// check collisions with player
int hit = lm.player.checkCollisions(go.getHitbox());
if (hit > 0) {
 //collision! Now deal with different types
 switch (go.getType()) {

 default:// Probably a regular tile
 if (hit == 1) {// Left or right
 lm.player.setxVelocity(0);
 lm.player.setPressingRight(false);
 }

 if (hit == 2) {// Feet
 lm.player.isFalling = false;
 }

 break;
 }
}

注意事项

随着这个项目的进行,我们将更多地利用在hit中存储的值进行基于碰撞的决策。

让我们真正地控制玩家。

玩家输入

首先,在Player类中添加一些方法,我们的输入控制器将能够调用这些方法,然后操作Player类的update方法用来移动的变量。

我们已经玩过了isPressingRight变量,也有一个isPressingLeft变量。此外,我们希望能够跳跃。如果你查看Player类的update方法,我们已经有处理这些情况的代码了。我们只需要玩家能够通过触摸屏幕来启动这些动作。

我们之前的按钮布局设计和到目前为止编写的代码,暗示了一种向左走的方法,一种向右走的方法,以及一种跳跃的方法。

你还会注意到,我们将SoundManager的副本传递给startJump方法,这使得如果跳跃尝试成功,我们可以播放一个整洁的复古跳跃声音。

public void setPressingRight(boolean isPressingRight) {
        this.isPressingRight = isPressingRight;
    }

    public void setPressingLeft(boolean isPressingLeft) {
        this.isPressingLeft = isPressingLeft;
    }

    public void startJump(SoundManager sm) {
        if (!isFalling) {//can't jump if falling
            if (!isJumping) {//not already jumping
                isJumping = true;
                jumpTime = System.currentTimeMillis();
                sm.playSound("jump");
            }
        }
    }

现在,我们可以专注于InputController类。让我们从onTouchEvent方法中将控制权传递给我们的InputController类。在PlatformView类中更改onTouchEvent方法的代码如下:

@Override
    public boolean onTouchEvent(MotionEvent motionEvent) {
        if (lm != null) {
            ic.handleInput(motionEvent, lm, sm, vp);
        }
        //invalidate();
        return true;
    }

我们的新方法中有一个错误。这仅仅是因为我们调用了handleInput方法,但还没有实现它。我们现在就来做这件事。

注意

如果你好奇为什么需要检查lm != null,这是因为onTouchEvent方法是从 Android UI 线程触发的,不在我们的控制范围内。如果我们传入lm并尝试用它做事情,而它尚未初始化,游戏将会崩溃。

我们现在可以在InputController类中完成我们需要做的一切。现在打开这个类,我们将计划我们要做什么。

我们需要一个向左的按钮,一个向右的按钮,一个跳跃按钮,一个切换暂停的按钮,稍后我们还需要一个发射机枪的按钮。因此,我们确实需要突出屏幕的不同区域来代表这些任务。

为了实现这一点,我们将声明四个Rect对象,每个任务一个。然后在构造函数中,我们将通过基于玩家屏幕分辨率进行一些简单的计算来定义这四个Rect对象的点。

我们根据设备屏幕分辨率定义了一些方便的变量,buttonWidthbuttonHeightbuttonPadding,以帮助我们整齐地排列Rect坐标。输入以下成员和InputController构造函数,如下所示:

import android.graphics.Rect;
import android.view.MotionEvent;
import java.util.ArrayList;

public class InputController {

    Rect left;
    Rect right;
    Rect jump;
    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);

        jump = 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);

    }

我们将使用这四个Rect对象在屏幕上绘制按钮。draw方法将需要它们的副本。输入getButtons方法的代码以实现这一点:

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(jump);
   currentButtonList.add(shoot);
   currentButtonList.add(pause);
   return  currentButtonList;
}

我们现在可以处理实际的玩家输入。这个项目与上一个项目不同,因为有大量不同的玩家动作需要监控和响应,有时是同时进行的。正如你所期望的,Android API 具有使这尽可能简单的功能。

MotionEvent类中隐藏的数据比我们目前看到的要多。之前,我们只是检查了ACTION_DOWNACTION_UP事件。现在,我们需要更深入地挖掘以获取更多的事件数据。

为了记录和传递多个手指在屏幕上触摸、离开和移动的详细信息,MotionEvent 类将它们都存储在一个数组中。当玩家的第一个手指触摸屏幕时,详细信息、坐标等存储在位置零。后续动作随后存储在数组的后面。

与任何手指活动相关的数组中的位置并不一致。在某些情况下,例如检测特定的手势时,这可能是个问题,程序员需要捕获、记住并响应对应于 MotionEvent 类中保存的手指 ID。

幸运的是,在这种情况下,我们有明确定义的屏幕区域来表示我们的按钮,我们最多需要知道的是,玩家的手指是否在这些预定义的区域内按下或释放了屏幕。

我们只需通过调用 motionEvent.getPointerCount() 来找出导致事件的手指数量,进而得知它们存储在数组中的情况。然后,我们遍历这些事件,并提供一个 switch 代码块来处理它们,无论在屏幕的哪个区域发生了 ACTION_DOWNACTION_UP。只要我们能够检测到事件并对其作出响应,事件存储在数组的哪个位置都无关紧要。

在我们编写解决方案的代码之前,还需要了解的另外一点是,数组中后续的动作被存储为 ACTION_POINTER_DOWNACTION_POINTER_UP;因此,在即将编写的循环中,每次通过时,我们都需要检查并处理 ACTION_DOWNACTION_POINTER_DOWN

在所有这些讨论之后,以下是每次屏幕被触摸或释放时调用的 handleInput 方法:

public void handleInput(MotionEvent motionEvent,LevelManager l,     
  SoundManager sound, Viewport vp){

    int pointerCount = motionEvent.getPointerCount();

    for (int i = 0; i < pointerCount; i++) {

        int x = (int) motionEvent.getX(i);
        int y = (int) motionEvent.getY(i);

        if(l.isPlaying()) {
            switch  (motionEvent.getAction() &
            MotionEvent.ACTION_MASK) {

            case MotionEvent.ACTION_DOWN:
                    if (right.contains(x, y)) {
                    l.player.setPressingRight(true);
                    l.player.setPressingLeft(false);

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(true);
                    l.player.setPressingRight(false);

                    } else if (jump.contains(x, y)) {
                    l.player.startJump(sound);

                    } else if (shoot.contains(x, y)) {

                    } else if (pause.contains(x, y)) {
                    l.switchPlayingStatus();
                    }

                break;

                case MotionEvent.ACTION_UP:
                    if (right.contains(x, y)) {
                    l.player.setPressingRight(false);

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(false);
                }

                break;

                case MotionEvent.ACTION_POINTER_DOWN:
                if (right.contains(x, y)) {
                    l.player.setPressingRight(true);
                    l.player.setPressingLeft(false);

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(true);
                        l.player.setPressingRight(false);

                    } else if (jump.contains(x, y)) {
                    l.player.startJump(sound);

                    } else if (shoot.contains(x, y)) {
                    //Handle shooting here

                    } else if (pause.contains(x, y)) {
                    l.switchPlayingStatus();
                }

                    break;

                case MotionEvent.ACTION_POINTER_UP:
                    if (right.contains(x, y)) {
                    l.player.setPressingRight(false);
                   //Log.w("rightP:", "up" );

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(false);
                   //Log.w("leftP:", "up" );

                    } else if (shoot.contains(x, y)) {
                    //Handle shooting here
                    } else if (jump.contains(x, y)) {
                   //Handle more jumping stuff here later
                }

                break;
}// End if(l.playing)

}else {// Not playing
    //Move the viewport around to explore the map
    switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {

    case MotionEvent.ACTION_DOWN:

        if (pause.contains(x, y)) {
            l.switchPlayingStatus();
            //Log.w("pause:", "DOWN" );
        }

      break;
            }
        }
    }
}
}

注意

如果你好奇为什么我们要设置两组控制代码,一组用于播放,一组用于不播放,那是因为在第八章《组合在一起》中,我们将为游戏暂停时添加一个很酷的新功能。当然,togglePlayingStatus 方法不必这样做,即使没有播放状态的检测也能正常工作。这只是为我们稍后对代码进行微小的精细修改节省时间。

现在,我们需要做的就是打开 PlatformView 类,获取包含所有控制按钮的数组副本,并将它们绘制到屏幕上。我们使用 drawRoundRect 方法绘制整洁的圆角矩形,以表示屏幕上将对玩家的触摸作出响应的区域。在 draw 方法的 unlockCanvasAndPost() 调用之前输入以下代码:

//draw buttons
paint.setColor(Color.argb(80, 255, 255, 255));
ArrayList<Rect> buttonsToDraw;
buttonsToDraw = ic.getButtons();

for (Rect rect : buttonsToDraw) {
  RectF rf = new RectF(rect.left, rect.top, 
    rect.right, rect.bottom);

    canvas.drawRoundRect(rf, 15f, 15f, paint);
}

同样,在我们调用 unlockCanvasAndPost() 之前,让我们绘制一个简单的暂停屏幕,这样我们就可以知道游戏是暂停还是正在播放。

//draw paused text
if (!this.lm.isPlaying()) {
    paint.setTextAlign(Paint.Align.CENTER);
    paint.setColor(Color.argb(255, 255, 255, 255));

    paint.setTextSize(120);
    canvas.drawText("Paused", vp.getScreenWidth() / 2,                       
    vp.getScreenHeight() / 2, paint);
}

现在你可以到处跳跃和行走,同时还会播放一段不错的复古跳跃音效。为何不通过编辑LevelCave并向场景中添加更多草地,用一些1字符替换几个句点(.)字符呢?下一张截图显示了玩家已经跳跃了一段时间,以及用于控制的按钮:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意

我们将设计一些真正可玩的游戏关卡,并在第八章,将其全部组合在一起中链接它们。现在,只需用LevelCave做任何看起来有趣的事情。

现在,我们可以摆脱那个难看的压缩玩家图像,并使其成为一个整洁的小动画。

动画鲍勃

精灵表动画通过快速更改屏幕上绘制的图像来工作。这就像一个孩子在书本的角落里画出火柴人的动作阶段,然后快速翻动书本,使其看起来像是在移动。

鲍勃的动画帧已经包含在我们一直用来表示他的player.png文件中。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们需要做的就是在玩家移动时逐个遍历这些帧。

实现这一点非常直接。我们将制作一个简单的动画类,处理保持时间和在请求时返回精灵表适当部分的功能。然后,我们可以为任何需要动画的GameObject初始化一个新的动画对象。此外,当它们在PlatformViewdraw方法中被绘制时,如果对象是动画的,我们将稍微不同地处理它。

在本节中,我们还将了解如何使用面对变量来跟踪玩家面向的方向。它将使我们能够根据玩家(或任何未来的动画对象)前进的方向来反转精灵表。

让我们先创建一个动画类。创建一个新的 Java 类,将其命名为Animation。接下来的代码将声明用于操作位图的变量、位图名称以及一个rect参数,以定义精灵表当前相关动画帧的区域坐标。

此外,我们还有frameCountcurrentFrameframeTickerframePeriod,它们分别保存和控制可用的帧数、当前帧编号以及帧变化的时间。如您所料,我们还需要知道动画帧的宽度和高度,这些由frameWidthframeHeight保存。

此外,Animation类将经常引用每米的像素数;因此,将这个值保存在成员变量中是有意义的。

我们来输入在Animation类中讨论过的成员变量:

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;

public class Animation {
    Bitmap bitmapSheet;
    String bitmapName;
    private Rect sourceRect;
    private int frameCount;
    private int currentFrame;
    private long frameTicker;
    private int framePeriod;
    private int frameWidth;
    private int frameHeight;
    int pixelsPerMetre;

接下来,我们有构造函数,它为我们的动画对象做好准备。我们很快就会看到如何为实际动画做准备。注意,签名中有相当多的参数,表明动画是相当可配置的。只需注意,这里的 FPS 不是指游戏的帧率,而是指动画的帧率。

Animation(Context context, 
  String bitmapName, float frameHeight, 
  float frameWidth, int animFps, 
  int frameCount, int pixelsPerMetre){

   this.currentFrame = 0;
   this.frameCount = frameCount;
   this.frameWidth = (int)frameWidth * pixelsPerMetre;
   this.frameHeight = (int)frameHeight * pixelsPerMetre;
   sourceRect = new Rect(0, 0, this.frameWidth, this.frameHeight);

   framePeriod = 1000 / animFps;
   frameTicker = 0l;
   this.bitmapName = "" + bitmapName;
   this.pixelsPerMetre = pixelsPerMetre;
}

我们可以处理类的实际功能。getCurrentFrame方法首先检查对象是否在移动或是否能够移动。在这个阶段,这可能看起来有点奇怪,因为该方法只会被一个已动画化的GameObject类调用。因此,这个奇怪的检查是确定此刻是否需要一个新帧。

如果一个对象移动(比如 Bob),但处于静止状态,那么我们不需要改变动画的帧。然而,如果一个动画对象从不具有速度,比如熊熊燃烧的火焰,那么我们需要一直动画它。它永远不会有任何速度,所以moves变量将是false,但方法将继续执行。

该方法然后使用timeframeTickerframePeriod来确定是否到了显示动画下一帧的时间,并递增要显示的帧号。然后,如果动画在最后一帧,它会回到第一帧。

最后,计算代表精灵表中包含所需帧的精确左右位置,并将这些位置返回给调用代码。

public Rect getCurrentFrame(long time, 
    float xVelocity, boolean moves){

    if(xVelocity!=0 || moves == false) {
    // Only animate if the object is moving 
    // or it is an object which doesn't move
    // but is still animated (like fire)

        if (time > frameTicker + framePeriod) {
            frameTicker = time;
            currentFrame++;
            if (currentFrame >= frameCount) {
                currentFrame = 0;
            }
        }
    }

    //update the left and right values of the source of
    //the next frame on the spritesheet
    this.sourceRect.left = currentFrame * frameWidth;
    this.sourceRect.right = this.sourceRect.left + frameWidth;

    return sourceRect;

}

}// End of Animation class

接下来,我们可以向GameObject类添加一些成员。

// Most objects only have 1 frame
// And don't need to bother with these
private Animation anim = null;
private boolean animated;
private int animFps = 1;

一些与我们的Animation类交互的方法,设置和获取变量,使动画工作,并通知draw方法对象是否已动画化。

public void setAnimFps(int animFps) {
  this.animFps = animFps;
}

public void setAnimFrameCount(int animFrameCount) {
  this.animFrameCount = animFrameCount;
}

public boolean isAnimated() {
  return animated;
}

最后,在GameObject中,有一个方法,需要动画的对象可以使用它来设置它们的整个动画对象。注意,是setAnimated方法在一个新的动画对象上调用new()

public void setAnimated(Context context, int pixelsPerMetre,  
  boolean animated){

 this.animated = animated;
 this.anim = new Animation(context, bitmapName,
     height,
     width,
     animFps,
     animFrameCount,
     pixelsPerMetre );
}

下一个方法作为PlatformView类的draw方法和Animation类的getRectToDraw方法之间的中介。

public Rect getRectToDraw(long deltaTime){
  return anim.getCurrentFrame(
    deltaTime, 
    xVelocity, 
    isMoves());
}

然后,我们需要更新Player类,以便根据其特定的帧数和每秒帧数初始化其动画对象。Player类中的新代码如下所示:

setBitmapName("player");

final int ANIMATION_FPS = 16;
final int ANIMATION_FRAME_COUNT = 5;

// Set this object up to be animated
setAnimFps(ANIMATION_FPS);
setAnimFrameCount(ANIMATION_FRAME_COUNT);
setAnimated(context, pixelsPerMetre, true);

// X and y locations from constructor parameters
setWorldLocation(worldStartX, worldStartY, 0);

我们可以使用draw方法中的所有新代码来实现我们的动画。下一块代码检查当前正在绘制的GameObject是否isAnimated()。如果是,它通过GameObject类的getRectToDraw方法使用getNextRect()方法从精灵表中获取适当的矩形。

注意,从原始的draw方法中调用drawBitmap()的下一行代码,现在被包裹在新代码末尾的一个else子句中。基本上,逻辑是这样的:如果需要动画,执行新代码,否则按常规方式处理。

除了我们已知的动画代码外,我们还检查 if(go.getFacing() == 1) 并使用 Matrix 类在必要时通过 x 轴缩放 -1 来翻转位图。

这里是所有新代码,包括原始的 drawBitmap() 调用,在最后的 else 子句中进行了包装:

toScreen2d.set(vp.worldToScreen
  go.getWorldLocation().x,
  go.getWorldLocation().y,
  go.getWidth(),
  go.getHeight()));

if (go.isAnimated()) {
 // Get the next frame of the bitmap
 // Rotate if necessary
 if (go.getFacing() == 1) {
 // Rotate
 Matrix flipper = new Matrix();
 flipper.preScale(-1, 1);
 Rect r = go.getRectToDraw(System.currentTimeMillis());
 Bitmap b = Bitmap.createBitmap(
 lm.bitmapsArray[lm.getBitmapIndex(go.getType())],
 r.left,
 r.top,
 r.width(),
 r.height(),
 flipper,
 true);
 canvas.drawBitmap(b, toScreen2d.left, toScreen2d.top, paint);
} else {
 // draw it the regular way round
 canvas.drawBitmap(
 lm.bitmapsArray[lm.getBitmapIndex(go.getType())],
 go.getRectToDraw(System.currentTimeMillis()),
 toScreen2d, paint);
}
} else { // Just draw the whole bitmap
 canvas.drawBitmap(
 lm.bitmapsArray[lm.getBitmapIndex(go.getType())],
 toScreen2d.left,
 toScreen2d.top, paint);
}

现在,您可以运行游戏,并看到 Bob 的所有动画效果。截图无法展示他的动作,但您可以看到他现在形态完美:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

总结

我们的游戏正在稳步成型。在这个阶段,我们可以在 LevelCave 中构建一个巨大的关卡设计,并在各处奔跑跳跃。然而,我们会推迟尝试使游戏可玩,直到我们添加了更多整洁的特性为止。

这些整洁的特性将包括一挺机关枪,这挺枪可以通过收集升级物品和 Bob 可以射击的一些敌人来进行升级。我们将在下一章开始介绍这些内容。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值