五、Platformer——升级游戏引擎

欢迎来到本书的第二个项目。在这里,我们将构建一个真正强悍的复古平台游戏。建造它并不难,但当你玩它时,很难打败它。在项目的最后,我们还将讨论如何让游戏玩得不那么惩罚,如果你愿意的话。

这一章将完全集中在我们的游戏引擎上,本质上导致了 Tappy Defender 代码的升级版本。

首先,我们将讨论我们想通过这个游戏实现什么:背景故事、游戏机制和规则。

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

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

然后,我们将开始构建我们的GameObject类的迭代过程,游戏世界的几乎每个实体都将从这个类中派生出来。

接下来,我们将讨论一个视窗的概念,玩家可以通过它来查看游戏世界。我们将不再设计我们的游戏对象在屏幕分辨率的水平上运行,但它们现在将存在于一个有自己的 xy 坐标的世界中,我们可以把它们想象成虚拟仪表。在 z 轴上也有一个简单的深度系统。这将由我们新的Viewport班处理。

在这之后,我们将看看我们如何设计和布局我们的游戏内容。这是通过一个类来完成的,这个类被用作一个关卡设计器,并且可以用一种非语法的方式来绘制构成关卡布局的跳跃、敌人、奖励和目标。

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

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

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

比赛

我们将打造的游戏是基于 80 年代一些残酷的硬平台游戏的游戏玩法,比如《赏金鲍勃反击战》和《不可能的任务》。这些游戏的特点是高难度跳跃,需要极其精确的时间,同时给玩家不可饶恕的生命/机会。这种风格的游戏对我们来说很有效,因为我们实际上只需四章就可以构建一个多级可玩的游戏。

这些类的设计会让你很容易添加你自己的额外功能和游戏对象,或者让它在你想玩的时候变得不那么有挑战性。

背景故事

我们的英雄鲍勃,刚刚从地球中心消灭邪恶科学家的秘密任务中回来,发现他在地下深处。更糟糕的是,虽然他打败了邪恶的科学家,但他释放的强大守卫和致命的飞行机器人无人机并没有及时拯救地球。

鲍勃必须从深深的地下火坑中走出来,穿过戒备森严的城市和高山上的森林,他希望在那里生活,摆脱已经占领这个星球的可怕的新秩序。

在他穿越这四个关卡的旅程中,他必须避开守卫,摧毁无人机,收集大量资金,并升级他最初微不足道的机枪。

游戏机制

游戏将是关于执行精确的跳跃,计划通过关卡收集战利品和逃跑的最佳路线。鲍勃将能够站立在突出的壁架上,整个像素的脚悬在上面,能够进行看似不可能的跳跃。鲍勃将能够在跳跃时控制他行进的距离,这意味着有时他经常需要确保他不会过度跳跃。

鲍勃在试图通过戒备森严的区域逃跑之前需要收集机枪升级。

鲍勃只有三条命,但在旅途中也许能找到更多。

游戏规则

当鲍勃因被无人机/守卫抓到、接触火焰或从游戏世界中掉出而失去一条生命时,他将在当前等级开始时重生。无人机可以飞行,鲍勃一出现,无人机就会飞回家。鲍勃需要确保他有足够的火力来操纵无人机。守卫会在关卡预定的地方巡逻,但是他们很强悍,只能被鲍勃的机枪打回去。通常,鲍勃需要执行一次精确定时的跳跃来越过一名后卫。

环境也会很艰难。鲍勃将需要完全掌握每一关,因为一次错误的跳跃会让他一落千丈,直接落入敌人的魔掌,甚至导致他的暴死。

升级游戏引擎

所有关于守卫、无人机、火、收藏品、枪的谈论,以及隐含的更大的游戏世界暗示着一个更复杂的管理系统。我们游戏引擎的目标之一是让这种复杂性易于管理。另一个目标是将层次设计与编码分开。当我们的游戏完成后,你将能够在多个不同的环境中,在不接触代码的情况下,坐下来设计最邪恶但有回报的关卡。

平台活动

首先我们从Activity类开始,这是我们游戏的切入点。这里没有太多的新东西,所以让我们继续快速建造它。创建一个新项目,在应用名称字段中,输入C5 Platform Game。选择手机和平板,出现提示后选择空白活动。在活动名称字段中,键入PlatformActivity

类型

显然,你不必遵循我的确切命名选择,但只要记住在代码中做一些小的修改,以反映你自己的命名选择。

您可以从layout文件夹中删除activity_platform.xml。您也可以删除PlatformActivity.java文件中的所有代码。留下包裹申报单就行了。现在,我们有一个完全空白的画布准备开始编码。这是到目前为止我们项目的全部内容:

package com.gamecodeschool.c5platformgame;

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

我们将对我们的发动机进行一些重大升级,但这将主要发生在视图中。在我们接下来要看的PlatformActivity类的代码中,我们做了与上一个项目基本相同的事情。首先,在被覆盖的onCreate方法中声明PlatformView对象并将其设置为主视图;然而,在我们这样做之前,我们还要捕捉并传入设备屏幕的分辨率。

我们使用Display类并链接getWindowManager()getDefaultDisplay()方法来获得我们的游戏将要运行的物理显示硬件的属性。然后,我们创建一个名为分辨率的类型为Point的对象,并通过调用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 | 输入键盘组合。

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文件中,找到下面一行代码:

    java android:name=".PlatformActivity"

  3. 紧接着,键入或复制粘贴这两行使PlatformActivity全屏运行并锁定在横向。

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

现在,我们可以进入游戏的真正核心,看看如何实现我们谈到的所有这些改进。

平台视图类

当完成时,这个类将依赖于许多其他类。我不想只是依次展示每个类,因为这很难理解,具体是什么代码实现了哪个特性将变得令人困惑。相反,我们将根据需要依次查看并编码每个特性,然后多次重新访问许多类以添加更多特性。这将把重点放在代码每个部分的特定目的上。

说到这里,我们已经非常小心了,虽然我们会多次重新访问这些类,但是我们不会不断地删除代码,只是添加代码。当我们添加它时,代码将在适当的上下文中呈现,新的部分在现有代码中突出显示。

关于类的结构,它们被设计成尽可能最小,同时不限制您轻松添加特性和扩展代码的潜力。

这不是一堂游戏引擎设计的课,更像是一堂看看我们能学会实现多少不同的功能,并塞进四章,而不会让代码变得不可管理的课。

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

类型

如果这种类型的讨论让你感兴趣,我强烈推荐这本书,马里奥·泽奇纳的开始安卓游戏,由 APRESS 出版。Mario 是 LibGDX 跨平台游戏库的创始人/创造者,他的书非常详细地介绍了为游戏构建高度可扩展和可重用的代码库所需的设计模式。这本书的伟大设计细节的唯一缺点是,它需要大约 600 页来构建一个简单的复古蛇游戏。

首先,让我们创建类。在 Android Studio 项目浏览器中右击包名,导航到 | Java 类。调用新类PlatformView。删除类的自动生成内容,因为我们很快会添加自己的代码。

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

我们需要一个能管理我们水平的班级。姑且称之为LevelManager

我们还需要一个类来保存我们级别的数据,因为我们可以在每次创建新的/不同的级别设计时扩展它。我们把父类叫做LevelData,我们第一个让 Bob 逃离的真实关卡,LevelCave

此外,由于这个游戏将有许多敌人,道具和地形类型,我们将需要一个更干净的系统来管理它们。我们需要一个相当通用的GameObject类,所有不同的游戏对象都可以扩展这个类。然后,我们可以用我们的updatedraw方法轻松管理它们。

我们还将根据需要建立一个稍微复杂一点的方法来检测玩家的输入。我们将创建一个InputController类来委托PlatformView的所有代码。然而,这个类的细节我们不会看到,直到我们在下一章完全充实了我们的Player对象来代表玩家。

我们可以用与第一个项目非常相似的代码快速编写我们的基本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方法,当相应的活动生命周期方法被操作系统调用时,它们被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类的世界位置。本课程将详细介绍 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。然后我们有两个浮动成员,它们将保持GameObject类的宽度和高度。接下来,我们将使用布尔变量activevisible,当对象处于活动、可见或其他状态时,它们可能会标记对象。我们将在本章的后面部分开始了解这是如何带来好处的。

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

然后我们有一个char类叫做type。这个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图像文件中制作一个安卓资源标识。该文件必须存在于项目的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在世界的什么地方,当然也希望能够设定它在世界的什么地方。这里有一个 getter 和一个 setter,它们就是这么做的。

    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类知道该关注哪里。

这里是Player班,这个将代表我们的英雄鲍勃。这个阶段的类和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文件夹中。该图形是一个多帧精灵表,所以它不会很好地显示,直到我们在第 6 章平台-鲍勃、哔哔声和颠簸中对其进行动画制作,但它现在将作为占位符。

正如我们接下来将看到的,玩家看到的游戏世界的视图将集中在鲍勃身上,这可能是您所期望的。

通过视口的视图

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

它还通过确定哪些对象在玩家的视野之内和之外,提供了使我们的绘制方法更有效的组合功能。如果一群敌人在任何给定的时刻都不相关,那么绘制或处理他们是没有意义的。

这将通过从对象列表中移除屏幕外的对象来实现第一阶段的检测,从而显著加快碰撞检测等任务的速度,而且操作起来非常简单。

此外,我们的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

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

pixelsPerMetreXpixelsPerMetreY是分别除以 32 和 18 计算出来的(同样是 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()。它接收到一个 x 和一个 y 参数,该参数被立即指定为currentWorldCentre。我们需要这种方法,因为玩家当然会在世界上移动,我们需要让Viewport类知道鲍勃在哪里。此外,正如我们将在第 8 章中看到的那样,综合起来,我们也会有不希望鲍勃成为关注中心的情况。

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指定为剪辑。

然后,四个嵌套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,并添加这个代码。在这个阶段,它只为Strings持有一个ArrayList对象。

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方法的调用决定,我们很快就会看到。我通常会把【玩家的 T4】作为第一个元素放在地图的第一行,这样就不太可能被遗忘。

现在,我们来谈谈这个级设计将如何运作。我们将在代码的tiles.add("..."部分的LevelCave类中输入字母数字字符。我们将输入一个不同的字母数字字符,这取决于我们想要将哪个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的对象,一个ArrayList对象来保存我们所有的GameObject对象,一个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类时被确定,screenWidth再次直接来自Viewport类,一个我们的InputController类的副本,然后是要加载的级别的名称。int参数pxpy是玩家的起始坐标。

我们将级别参数分配给我们的成员级别,然后切换到确定哪个类将是我们的当前级别。当然,目前我们只有LevelCave

然后,我们初始化我们的gameObject ArrayList和我们的bitmapsArray。然后我们称之为loadMapData(),这是一个我们很快会写的方法。在这之后,我们将playing设置为true,最后我们有一个 getter 方法来找出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

这个下一个方法将使我们能够得到index来调用getBitmap方法。只要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();

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

我们检查是否有除了空格以外的对象。)出现在当前位置,如果是,我们输入一个开关块,在指定位置创建适当的对象。

如果我们遇到一个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传入一些数据。当我们在第 6 章鲍勃、哔哔声和颠簸声中构建我们的InputController类时,我们将确切地看到我们用这些数据做了什么。最后,我们调用vp.setWorldCentre()并将其传递到玩家所在的位置作为坐标。这将屏幕对准鲍勃。

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类的一个主要函数。

增强更新方法

最后,我们可以使用我们便利的游戏对象和我们的Viewport功能来充实我们增强的update方法。在接下来的代码中,我们简单地使用一个增强的for循环来遍历每个GameObject。我们检查它是否isActive(),然后将对象的位置和尺寸发送到clipObjects()并包装在if声明中。如果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

            }
        }

    }
}

增强的绘制方法

现在,我们可以更精确地知道我们需要画哪些物体了。首先,我们声明并初始化一个名为toScreen2d的新Rect对象。

然后,我们从最低层开始,对每一层循环一次gameObjects ArrayList。在这个阶段,这并不是绝对必要的,因为默认情况下,我们所有的对象都在第 0 层。在项目结束之前,我们将在第 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()

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

The enhanced draw method

注意图中草的精确布局来自我们的LevelCave设计。你还可以看到我们压扁的鲍勃雪碧表和事实上有 28 个对象,但其中 10 个已经被剪辑。随着我们的水平变得更大,修剪与未修剪的比例将显著增加,有利于绝大多数被修剪。

总结

我们在这一章已经讨论了很多内容,现在有了一个完善的游戏引擎。

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

在下一章中,我们将添加音效和输入检测,从而让 Bob 栩栩如生。然后,我们会看到他的世界有多危险,会及时添加碰撞检测,让他能站在一个平台上。