二、管理用户输入

在本章中,我们将学习如何以通用方式处理用户输入,并在以后将其扩展为虚拟操纵杆、传感器或外部控制器。

为了获得输入的视觉反馈,我们将在屏幕上放置一艘宇宙飞船并移动它。我们还会让它发射一些子弹。这也将帮助你理解游戏对象和游戏引擎之间的交互。

我们将扩展通用的InputController类,使最简单的键盘控制器成为可能,以了解该类如何适应现有的体系结构,以及如何在游戏对象内部处理和读取输入。

一旦我们让基本的键盘工作,我们将实现一个虚拟操纵杆,这是一个更好的处理用户输入的方式。

管理物理控制器对于我们正在编写的游戏也很重要,所以我们将看到如何检测它们并处理不同的选项。

最后,我们将谈一谈使用传感器作为控制。它们不太适合这种类型的游戏,但是如果您想进一步探索,我们将介绍基础知识并提供一些链接。

输入控制器基类

我们如何控制比赛并不重要;它总是可以抽象为一个两轴操纵杆和一个发射按钮。对于其他游戏来说,这可能会有所不同,但是您应该总是能够从用户那里提取基本动作,并创建一个处理它们的InputController。我们正在构建的InputController对任何使用方向控制的游戏都很有用。

输入控制器对每种类型的游戏都非常具体。

我们将考虑一个标准化的水平和垂直轴作为输入(从-1 到 1)。在控制器没有范围的情况下,我们将把它设置为最大值。当输入类型允许时,这将允许我们精确地处理用户输入,就像虚拟和真实操纵杆以及传感器的情况一样。

The InputController base class

电话屏幕的坐标系

提醒一下,电脑屏幕上的坐标在左上角有[0,0],向右下方呈正方向。右下角有坐标【宽、高】。这与我们习惯的标准坐标系不同,但你记住它非常重要。

这就是为什么向左移动是-1,向顶部移动也是-1。

我们游戏中所有输入控制器的基类如下:

public class InputController {

  public double mHorizontalFactor;
  public double mVerticalFactor;

  public boolean mIsFiring;

  public void onStart() {
  }

  public void onStop() {
  }

  public void onPause() {
  }

  public void onResume() {
  }
}

请注意,这是一个带有公共变量的类。这样做是为了避免通过方法读取值。我们在上一章中提到了这一点,作为性能改进。

这个类的每个实现将负责用更新的值填充这些变量。游戏对象可以在onUpdate期间读取它们。通过这样做,我们将使用来自游戏对象的值的的动作与读取用户输入分开。

InputController通过游戏对象隔离输入的读取和使用。

InputControllerGameEngine的一部分。我们只需向引擎添加一个这种类型的变量,并创建一个方法来设置它:

public InputController mInputController;

public void setInputController(InputController controller) {
  mInputController = controller;
}

方法onStartonStoponPauseonResumeGameEngine调用,在游戏开始、停止、暂停或恢复时调用。在这种情况下,一些输入控制器需要执行特殊操作。

最后,我们在GameFragment内部的发动机初始化过程中,将输入控制器添加到GameEngine中:

mGameEngine = new GameEngine(getActivity());
mGameEngine.addGameObject(new ScoreGameObject(view, R.id.txt_score));
view.findViewById(R.id.btn_play_pause).setOnClickListener(this);
mGameEngine.setInputController(new InputController());
mGameEngine.addGameObject(new Player(getView()));
mGameEngine.startGame();

目前,我们正在添加一个什么也不做的输入控制器。我们还添加了一个Player游戏对象,我们将在更详细地进入不同的输入控制器之前对其进行处理。

请注意,我们不再使用上一个例子中的了,它不应该被添加到游戏引擎中。

玩家对象

第一个版本的 Player游戏对象我们要去构建的时候只会在屏幕中间初始化它的坐标。然后,它将根据输入控制器中的信息更新它们,最后,它将在布局上的TextView中显示值为[x,y]。

之后,我们会让它显示一个位于坐标的飞船。但是现在,我们将关注onUpdate是如何实现的。

第一版Player类的onUpdateonDraw代码如下:

@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
  InputController inputController = gameEngine.inputController;
  mPositionX += mSpeedFactor*inputController.mHorizontalFactor*elapsedMillis;
  if (mPositionX < 0) {
    mPositionX = 0;
  }
  if (mPositionX > mMaxX) {
    mPositionX = mMaxX;
  }
  mPositionY += mSpeedFactor*inputController.mVerticalFactor*elapsedMillis ;
  if (mPositionY < 0) {
    mPositionY = 0;
  }
  if (mPositionY > mMaxY) {
    mPositionY = mMaxY;
  }
}

@Override
public void onDraw() {
  mTextView.setText("["+(int) (mPositionX)+","+(int) (mPositionY)+"]");
}

因此,在onUpdate的每次运行中,我们将使用相应的因子(我们从输入控制器中读取)、速度因子和经过的毫秒数来增加xy位置。这无非就是经典公式距离=速度时间*。

代码的其余部分确保 xy 位置保持在屏幕的边界内。

onDraw方法相当于ScoreGameObject的方法,只是在TextView中设置文字。

现在这段代码中有几个值我们还没有初始化。它们如下:

  • mSpeedFactor:每毫秒转换成像素的速度。
  • mMaxX:最大值为x。它将是视图的宽度减去填充。
  • mMaxY:最大值为y。它是视图的高度减去填充。
  • mTextView:我们设置当前坐标的视图。

所有这些元素都是在接收父视图作为参数的Player对象的构造函数上初始化的:

public Player(final View view) {
  // We read the size of the view
  double pixelFactor = view.getHeight() / 400d;
  mSpeedFactor = pixelFactor * 100d / 1000d;
  mMaxX = view.getWidth() - view.getPaddingRight() - view.getPaddingRight();
  mMaxY = view.getHeight() - view.getPaddingTop() - view.getPaddingBottom();

  mTextView = (TextView) view.findViewById(R.id.txt_score);
}

我们计算屏幕的像素因子,以 400 个单位的高度为参考。这是一个任意的数字,你可以使用任何对你有意义的东西。如果你考虑使用 400 像素的屏幕,然后让代码将其转换为真实的像素数量,这将会有所帮助。

这是一个类似于 dips 的概念,但也有所不同。虽然 dips 意味着在所有设备中具有相同的物理尺寸,但这些单元使我们的游戏规模更大。因此,无论设备的分辨率或大小如何,游戏的所有项目都将占用相同的屏幕空间。

我们将以“单位”来定义游戏空间,以便所有设备都具有相同的屏幕高度。

我们希望我们的飞船以每秒 100 个单位的速度移动,所以从屏幕底部到顶部移动需要 4 秒钟。因为我们需要以每毫秒像素为单位的速度,所以我们需要将所需的速度乘以像素因子(像素/单位),然后除以 1000(毫秒/秒)。

下一步是读取父视图的宽度和高度,并将其用作减去填充后的最大宽度和高度。

最后,我们在TextView中得到一个钩子,我们将使用它来显示坐标。

一旦完成初始化,我们还有startGame方法。在这个例子中,我们将把我们的玩家放在屏幕的中间。

@Override
public void startGame() {
  mPositionX = mMaxX / 2;
  mPositionY = mMaxY / 2;
}

如果你现在试着运行这个例子,你会看到这个位置停留在[0,0],表明出了问题。

问题是,我们在创建视图后直接读取视图的宽度和高度(在GameFragmentonViewCreated方法中)。此时此刻,景色尚未被测量。

在构造过程中,您无法获得视图的宽度和/或高度,因为它尚未被测量。

解决方法是延迟GameEngine的初始化,直到测量完视图。最好的方法就是使用ViewTreeObserver。我们去GameFragmentonViewCreated更新一下:

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
  super.onViewCreated(view, savedInstanceState);
  view.findViewById(R.id.btn_play_pause).setOnClickListener(this);
  final ViewTreeObserver obs = view.getViewTreeObserver();
  obs.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
      @Override
      public void onGlobalLayout() {
        if(Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
          obs.removeGlobalOnLayoutListener(this);
        }
        else {
          obs.removeOnGlobalLayoutListener(this);
        }
        mGameEngine = new GameEngine(getActivity());
        mGameEngine.setInputController(new BasicInputController(getView()));
        mGameEngine.addGameObject(new Player(getView()));
        mGameEngine.startGame();
      }
  });
}

我们获得刚刚为布局创建的视图的ViewTreeObserver,并向其中添加一个新的OnGlobalLayoutListener。我们将侦听器创建为匿名内部类。

每次执行全局布局时都会调用该侦听器。为了避免被多次调用,从而初始化多个引擎,我们需要在侦听器被调用后立即移除它。

不幸的是,在果冻豆之前的安卓版本中,用于移除侦听器的方法名称有一个错别字,所以我们必须为果冻豆之前的版本使用一个方法名称,为以后的版本使用另一个方法名称。

方法中剩下的代码是引擎初始化,之前是直接在onViewCreated中完成的。我们刚刚把它移到onGlobalLayout里面了。

请注意,虽然视图尚未测量,但它们已经创建并存在。因此,不需要将设置暂停按钮OnClickListener的代码移动到布局观察器。

如果我们继续运行这个版本,我们会看到坐标显示屏幕中心的像素值。

The Player object

展示宇宙飞船

如果我们不至少展示一艘宇宙飞船,这一切都不好玩,所以我们可以看到一些事情真的在发生。

我们将从 OpenGameArt 网站(http://opengameart.org)上获取游戏的图形,该网站包含多个免费的——就像在 freedom 中一样——游戏图形,其中大部分是在 Creative Commons 许可下的,这意味着你必须信任作者。

OpenGameArt.org 网站是一个伟大的游戏图形资源。

我们将要展示的宇宙飞船是由 Eikesteer 创造的,我们将在整个游戏中使用它们。

Displaying a spaceship

我们从开放游戏艺术中挑选的由艾克斯提尔制作的宇宙飞船套装

从布景来看,我们将使用右边第三个。我们可以用一个简单的编辑器比如 GIMP 把它提取到一个新的图像,放在drawable-nodpi目录下。

请注意,我们将缩放一切以与我们的 400 个屏幕高度单位保持一致,因此将图像放在具有密度限定符的可绘制目录中是没有意义的。这就是为什么我们要使用drawable-nodpi

drawable-nodpi目录意味着独立于任何密度,而drawable意味着没有限定符的图像。这意味着当我们试图读取可绘制图像的固有尺寸时,行为是不同的。当放置在nodpi下方时,固有尺寸将返回真实尺寸,并且当从drawable读取时将取决于设备。

我们将把我们的游戏对象图像放在drawable-nodpi文件夹中。

下一步是创建一个ImageView来展示我们的飞船。我们将在Player对象的构造函数中进行此操作:

public Player(final View view) {

  [...]

  // We create an image view and add it to the view
  mShip = new ImageView(view.getContext());
  Drawable shipDrawable = view.getContext().getResources()
    .getDrawable(R.drawable.ship);
  mShip.setLayoutParams(new ViewGroup.LayoutParams(
    (int) (shipDrawable.getIntrinsicWidth() * mPixelFactor),
    (int) (shipDrawable.getIntrinsicHeight() * mPixelFactor)));
  mShip.setImageDrawable(shipDrawable);

  mMaxX -= (shipDrawable.getIntrinsicWidth()*mPixelFactor);
  mMaxY -= (shipDrawable.getIntrinsicHeight()*mPixelFactor);

  ((FrameLayout) view).addView(mShip);
}

构造函数的第一部分保持不变。然后我们添加代码来创建ImageView并将Drawable加载到其中。

首先,我们使用父视图的Context创建一个ImageView,并将其存储为类变量。

然后,我们从资源中加载船舶的Drawable,并将其分配给shipDrawable局部变量。

我们继续为ImageView创建一个LayoutParams对象并设置它。既然我们已经有了图纸,我们可以为它指定确切的尺寸。为此,我们读取shipDrawable的固有宽度和高度,并将其乘以像素因子。

这意味着宇宙飞船的ImageView将被缩放到相当于一个 400 单位像素的屏幕。另一种说法是,如果显示在 400 像素高的屏幕上,宇宙飞船的大小将完全相同。然后将可拉伸性设置为ImageView

我们还必须通过减去船的大小来更新 xy 的最大值。这样,它就被放在了中心,不会超出边界。

最后将ImageView添加到父视图,预计为FrameLayout。这一新要求来自于能够在任何地方定位图像的需求。

这是我们需要更新的东西,否则我们会得到一个ClassCastException。我们正在更新fragment_game.xml布局,使其成为FrameLayout类型的顶部布局。

现在我们已经接触到了布局,我们还会将暂停按钮对齐到右上角,这是大多数游戏的暂停按钮所在的位置:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical"
  android:paddingTop="@dimen/activity_vertical_margin"
  android:paddingLeft="@dimen/activity_horizontal_margin"
  android:paddingRight="@dimen/activity_horizontal_margin"
  tools:context="com.plattysoft.yass.counter.GameFragment">

  <TextView
    android:layout_gravity="top|left"
    android:id="@+id/txt_score"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello_world" />

  <Button
    android:layout_gravity="top|right"
    android:id="@+id/btn_play_pause"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/pause" />
</FrameLayout>

最后,我们需要更新onDraw方法,使其在正确的位置显示飞船。为此,我们只需使用translateXtranslateYImageView翻译到屏幕上的预期位置。

这远非最佳,但我们将在下一章中继续绘制。目前,它用于在正确的位置显示图像:

@Override
public void onDraw() {
  mTextView.setText("["+(int) (mPositionX)+","+(int) (mPositionY)+"]");
  mShip.setTranslationX((int) mPositionX);
  mShip.setTranslationY((int) mPositionY);
}

如果我们启动我们的游戏,我们可以看到屏幕中间的飞船:

Displaying a spaceship

现在我们有了一艘宇宙飞船,是时候在混合物中加入一些子弹了。

发射子弹

宇宙飞船将发射子弹,这些子弹将向上移动,直到它们在屏幕之外。

正如我们在上一章的Good practices for game developers部分中提到的,我们将为我们将在Player类中创建的项目符号使用一个对象池:

List<Bullet> mBullets = new ArrayList<Bullet>();

private void initBulletPool() {
  for (int i=0; i<INITIAL_BULLET_POOL_AMOUNT; i++) {
    mBullets.add(new Bullet(mPixelFactor));
  }
}

private Bullet getBullet() {
  if (mBullets.isEmpty()) {
    return null;
  }
  return mBullets.remove(0);
}

private void releaseBullet(Bullet b) {
  mBullets.add(b);
}

它初始化我们希望在屏幕上某个点上有多少子弹。如果您在池中有项目时要求项目符号,它将删除一个并返回,但是如果列表为空,它将返回 null。你可以把这个数字作为影响游戏性的一个限制,或者你可以计算一下,使池足够大。

在我们的例子中,考虑到子弹的速度和射击间隔时间,我们发射的子弹不能超过 6 发。

回到水池,释放一颗子弹,我们将简单地把它放回列表中。

现在,在玩家的onUpdate期间,我们检查是否应该发射子弹:

@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
  updatePosition(elapsedMillis, gameEngine.mInputController);
  checkFiring(elapsedMillis, gameEngine);
}

private void checkFiring(long elapsedMillis, GameEngine gameEngine) {
  if (gameEngine.mInputController.mIsFiring
      && mTimeSinceLastFire > TIME_BETWEEN_BULLETS) {
    Bullet b = getBullet();
    if (b == null) {
      return;
    }
    b.init(mPositionX + mShip.getWidth()/2, mPositionY);
    gameEngine.addGameObject(b);
    mTimeSinceLastFire = 0;
  }
  else {
    mTimeSinceLastFire += elapsedMillis;
  }
}

我们检查输入控制器是否按下了点火按钮,冷却时间是否已经过去。如果我们想要并且能够发射一颗子弹,我们就从水池里拿一颗。

如果没有可用的项目符号(对象 b 为空),我们不做其他事情并返回。

一旦我们从池中获得一个Bullet,我们使用当前位置初始化它,并将其放置在飞船的中间。然后,我们将其添加到引擎中。最后,我们重置了上次火灾后的时间。

如果我们不能或不想发射,我们只需将经过的毫秒数加到最后一颗子弹发射后的时间上。

Firing bullets

在上图中,我们可以看到子弹与宇宙飞船的相对位置,以及为什么通过 x 坐标作为宇宙飞船的中心可以给子弹提供正确的信息。但是我们仍然需要增加一些补偿。

从这一刻起,所有关于子弹运动的逻辑都在Bullet物体内部完成。

子弹游戏对象

Bullet 也延伸了GameObject。和宇宙飞船一样,它也创建了一个ImageView并将可绘制的图形作为构造函数的一部分载入其中:

public Bullet(View view, double pixelFactor) {
  Context c = view.getContext();

  mSpeedFactor = pixelFactor * -300d / 1000d;

  mImageView = new ImageView(c);
  Drawable bulletDrawable = c.getResources().getDrawable(R.drawable.bullet);

  mImageHeight = bulletDrawable.getIntrinsicHeight() * pixelFactor;
  mImageWidth = bulletDrawable.getIntrinsicWidth() * pixelFactor;

  mImageView.setLayoutParams(new ViewGroup.LayoutParams(
    (int) (mImageWidth),
    (int) (mImageHeight)));
  mImageView.setImageDrawable(bulletDrawable);

  mImageView.setVisibility(View.GONE);
  ((FrameLayout) view).addView(mImageView);
}

这个构造函数和Player对象的唯一区别是我们将ImageView的可见性设置为GONE,因为除非发射子弹,否则子弹是不会显示的。Bullet还有一个mPositionXmPositionY用于绘图。

这些相似之处来自于两个游戏对象都是我们所说的精灵。精灵是一个 GameObject,它有一个相关的图像,并呈现在屏幕上。

精灵是一个游戏对象(通常是 2D 图像),在游戏中显示,并作为一个单一的实体进行操作。

在下一章中,我们将提取 sprite 的常见概念,并将它们放在一个基类中。

在构造器中,我们还将子弹的速度设置为每秒 300 个单位。这比宇宙飞船快 3 倍。你可以玩子弹之间的速度值和时间值,但记得测试它们在飞船向上移动时的连续射击中不会重叠。

如果修改子弹速度,可能还需要检查弹池大小。最糟糕的情况是将飞船放在屏幕底部持续开火。

下一个有趣的点是初始化。这是使用接收宇宙飞船位置的init方法完成的:

public void init(Player parent, double positionX, double positionY) {
  mPositionX = positionX - mImageWidth/2;
  mPositionY = positionY - mImageHeight/2;
  mParent = parent;
}

值得一提的是我们想把子弹定位在飞船的前方一点,并适当居中。由于成员变量mPositionXmPositionY指向图像的左上角,我们必须根据项目符号的大小对初始参数应用一个偏移量。

我们将子弹在垂直轴上定位在飞船外仅一半的位置( mImageHeight/2 ),以改善子弹从飞船射出的感觉。我们也以横轴为中心显示,这就是为什么我们还要减去最小宽度/2

上一节中的图像也将帮助您可视化此偏移。

因为Bullets是从GameEngine中添加和移除的,所以我们需要在中添加和移除它们时更改视图的可见性。这需要在UIThread上完成。为此,我们使用上一章中创建的回调:

@Override
public void onRemovedFromGameUiThread() {
  mImageView.setVisibility(View.GONE);
}

@Override
public void onAddedToGameUiThread() {
  mImageView.setVisibility(View.VISIBLE);
}

对视图的所有更改必须在UIThread上完成,否则将引发异常。

由于这些子弹也是小精灵,onDraw法几乎和玩家的一模一样。我们再次通过动画化视图并对其进行转换来实现:

@Override
public void onDraw() {
  mImageView.setTranslationX((int) mPositionX);
  mImageView.setTranslationY((int) mPositionY);
}

另一方面,onUpdate法有点不一样,细看很有意思:

@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
  mPositionY += mSpeedFactor * elapsedMillis;
  if (mPositionY < -mImageHeight) {
    gameEngine.removeGameObject(this);
    // And return it to the pool
    mParent.releaseBullet(this);
  }
}

类似于我们对玩家所做的,我们使用距离=速度时间*公式。但是,在这种情况下,根本没有来自InputController的影响。子弹有固定的垂直速度。

我们还会检查子弹是否在屏幕之外。因为我们在左上角画了项目,所以我们需要它完全消失。这就是为什么我们和mImageHeight比较。

如果子弹取出,我们将其从GameEngine中取出,并通过调用releaseBullet将其返回到水池中。

该游戏对象移除在GameEngineonUpdate循环内完成。如果此时修改列表,在GameEngine中执行onUpdate时会得到一个ArrayIndexOutOfBoundsException。这就是为什么removeGameObject方法会在调用onUpdate后将对象放在单独的列表中进行移除。

现在,所有这些都没用了除非我们能移动飞船发射子弹。让我们构建最基本的InputController

最基本的虚拟键盘

我们能做的最简单的事情就是在屏幕的左侧构建一个简单的十字形键盘,在它的右侧构建一个消防按钮。对于这个布局,我们将在layout文件夹下创建一个新文件,并将其称为view_keypad.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_gravity="bottom"
  android:padding="@dimen/keypad_size"
  android:layout_width="match_parent"
  android:layout_height="wrap_content">

  <Button
    android:id="@+id/keypad_up"
    android:layout_alignParentTop="true"
    android:layout_toRightOf="@+id/keypad_left"
    android:layout_width="@dimen/keypad_size"
    android:layout_height="@dimen/keypad_size" />

  <Button
    android:id="@+id/keypad_down"
    android:layout_below="@+id/keypad_left"
    android:layout_toRightOf="@+id/keypad_left"
    android:layout_width="@dimen/keypad_size"
    android:layout_height="@dimen/keypad_size" />

  <Button
    android:id="@+id/keypad_left"
    android:layout_alignParentLeft="true"
    android:layout_below="@+id/keypad_up"
    android:layout_width="@dimen/keypad_size"
    android:layout_height="@dimen/keypad_size" />

  <Button
    android:id="@+id/keypad_right"
    android:layout_toRightOf="@+id/keypad_up"
    android:layout_below="@+id/keypad_up"
    android:layout_width="@dimen/keypad_size"
    android:layout_height="@dimen/keypad_size" />

  <Button
    android:id="@+id/keypad_fire"
    android:layout_alignParentRight="true"
    android:layout_alignTop="@+id/keypad_left"
    android:layout_width="@dimen/keypad_size"
    android:layout_height="@dimen/keypad_size" />
</RelativeLayout>

我们有一个覆盖整个屏幕宽度的相对布局。它的layout_gravity设置为bottom,所以我们确信它会正确对齐。

我们的四按钮键盘排列在一个RelativeLayout中。左按钮与布局的左侧对齐,上按钮与布局的顶部对齐。然后,顶部和底部按钮被设置到左侧按钮的右侧。右边的设置在“向上”按钮的下方和右侧。最后,左边的设置在向上按钮的下方,向下按钮正好在左边的下方。听起来有点太复杂了,但是图像清晰多了。

The most basic virtual keypad

在屏幕的另一侧,与父按钮的右侧和左侧按钮的顶部对齐,我们有一个消防按钮。

您可能已经注意到,所有按钮都使用了一个名为keypad_size的特殊尺寸。这是非常重要的一点,不仅是为了使它们看起来都一样,而且是为了可用性。我们将其设置为 42 dp,这是触摸目标的建议最小尺寸。

可触摸项目的最小尺寸应为 42 dp。

随意把玩一下按钮的大小,自己观察一下,小一号的按钮很难摸到。事实上,对于一款游戏,我们应该始终使用大尺寸的触摸目标,有时比提供视觉反馈的区域还要大。控件的触摸区域越大越好。在这个例子中,击键的触摸区域可以和屏幕的右半部分一样大。

我们将在游戏片段中包含这个布局,这样我们就可以看到它是如何覆盖的。由于我们已经将顶部布局更新为FrameLayout,我们只需要使用一个include标签。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:paddingTop="@dimen/activity_vertical_margin"
  android:paddingLeft="@dimen/activity_horizontal_margin"
  android:paddingRight="@dimen/activity_horizontal_margin"
  tools:context="com.plattysoft.yass.counter.GameFragment">

  <TextView
    android:layout_gravity="top|left"
    android:id="@+id/txt_score"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello_world" />

  <Button
    android:layout_gravity="top|right"
    android:id="@+id/btn_play_pause"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/pause" />
  <include layout="@layout/view_keypad" />
</FrameLayout>

如果我们继续运行它,我们可以看到它看起来是什么样子。

The most basic virtual keypad

现在让我们为BasicInputController编写处理按钮的代码。从构造函数开始,代码如下:

public BasicInputController(View view) {
  view.findViewById(R.id.keypad_up).setOnTouchListener(this);
  view.findViewById(R.id.keypad_down).setOnTouchListener(this);
  view.findViewById(R.id.keypad_left).setOnTouchListener(this);
  view.findViewById(R.id.keypad_right).setOnTouchListener(this);
  view.findViewById(R.id.keypad_fire).setOnTouchListener(this);
}

我们将游戏控制器设置为所有按钮的触摸监听器:上、下、左、右和火。需要注意的是,我们必须使用OnTouchListener而不是OnClickListener

onClick回拨仅在按下按钮后释放时触发。在我们的例子中,我们需要知道按钮何时被按下,何时被释放。当按钮被按下时,我们需要移动宇宙飞船。这就是为什么我们需要OnTouchListener提供的更详细的回调。

BasicInputController中从OnTouchListener开始的方法的实现如下:

@Override
public boolean onTouch(View v, MotionEvent event) {
  int action = event.getActionMasked();
  int id = v.getId();
  if (action == MotionEvent.ACTION_DOWN) {
    // User started pressing a key
    if (id == R.id.keypad_up) {
      mVerticalFactor -= 1;
    }
    else if (id == R.id.keypad_down) {
      mVerticalFactor += 1;
    }
    else if (id == R.id.keypad_left) {
      mHorizontalFactor -= 1;
    }
    else if (id == R.id.keypad_right) {
      mHorizontalFactor += 1;
    }
    else if (id == R.id.keypad_fire) {
      mIsFiring = false;
    }
  }
  else if (action == MotionEvent.ACTION_UP) {
    if (id == R.id.keypad_up) {
      mVerticalFactor += 1;
    }
    else if (id == R.id.keypad_down) {
      mVerticalFactor -= 1;
    }
    else if (id == R.id.keypad_left) {
      mHorizontalFactor += 1;
    }
    else if (id == R.id.keypad_right) {
      mHorizontalFactor -= 1;
    }
    else if (id == R.id.keypad_fire) {
      mIsFiring = false;
    }
  }
  return false;
}

需要注意的是,我们称之为getActionMasked而不是getAction。在多个触摸指针的情况下,getAction包括指针信息,而该信息在作为屏蔽动作被请求时被移除。这就是为什么处理多点触控的推荐方法是使用getActionMaskedgetActionPointer。否则,您需要使用OR操作来检查动作,而不是等号,否则当读取第一个指针上方的指针时,它将不起作用。

使用getActionMaskedgetPointerIndex是处理多点触控的推荐方式。

我们有两个案子。当动作为MotionEvent.ACTION_DOWN时,表示用户已经按下了一个按钮,所以我们检查被触摸的视图的 ID,并相应地动作。

如果视图向上或向下,我们将垂直因子减 1 或加 1。类似地,如果触摸的按钮是左或右,我们将水平因子减 1 或加 1。

第二部分,我们处理MotionEvent.ACTION_UP动作,对相应的因子进行加减运算。

对于多点触控,我们是加和减,而不是将值设置为 1 或-1。例如,如果你先点击右边,然后点击左边,飞船应该停止,因为你同时按下了两个按钮。一旦你释放其中一个,运动就会恢复。

对于点火按钮,当其关闭时,我们将mIsFiring设置为true,当其打开时,我们将设置为false。很简单。

最后,我们返回false。这很重要,因为它告诉系统事件没有被我们的监听器消费,因此监听器链可以继续。这个侦听器链包括按钮自己的 click 侦听器,它负责将背景图像更改为与按钮状态一致的图像。如果我们返回 true,更新背景将不会发生。

OnTouch实现返回事件是否被这个监听器消费。

就这么简单——我们现在可以运行游戏了。我们会看到飞船在屏幕周围移动,还发射了一些子弹。最后,YASS 开始看起来像一个游戏。

局限性和问题

这么简单的键盘有几个限制和问题。除了按钮相当小且难以处理之外,其余的问题都来自于用户移动触摸指针的时候。

如果用户移动到按钮之外,API 级别 17 之前的 Android 版本将触发MotionEvent.ACTION_DOWN类型的事件,但从该 API 级别开始,它们不会。如果您想正确处理这种情况,您需要检查每个移动或动作,并验证它是否超出了原始视图手动取消的矩形范围。但这不是搬家的唯一问题。如果你轻击一个按钮并向另一个按钮移动,另一个按钮上的新轻击将不会被检测到,因为它是ACTION_MOVE而不是ACTION_DOWN

解决方法是检查每个事件中每个指针的位置,看它是否在按钮的矩形内,并相应地采取行动。

还有不能处理对角线动作的问题。

我们可以尝试解决这个键盘的这些问题。但是由于它无论如何都不是一个非常优雅的输入控制器,我们将向前移动,制作一个InputController,作为一个合适的虚拟操纵杆。

创建虚拟操纵杆

我们将改进用户输入,我们将通过创建一个虚拟操纵杆来实现。

一个虚拟操纵杆测量从触摸位置到其中心的距离,并使用该信息设置两个轴上的值。它表现为传统的模拟操纵杆。

因为它是虚拟的,所以我们不被限制在屏幕上的特定位置,所以我们可以把它放在玩家触摸它的任何地方。

然而,我们不能把整个屏幕当成虚拟操纵杆。还需要一个消防按钮。

我们经历过小触摸目标的挫折,所以我们要尽可能地把火按钮做大。这意味着我们将使用一半屏幕作为虚拟操纵杆,一半屏幕作为启动按钮。

我们将要使用的布局将有两个视图填充屏幕,每个视图覆盖一半的宽度。我们将这个布局命名为view_vjoystick.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:orientation="horizontal"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <View android:id="@+id/vjoystick_main"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    android:layout_weight="1"
  />
  <View android:id="@+id/vjoystick_touch"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    android:layout_weight="1"
  />
</LinearLayout>

这种布局的有趣之处在于安卓的用法:layout_weight将屏幕平均分成两半。如果您想为虚拟操纵杆或击键留出更大的空间,可以修改权重值以使一个视图比另一个视图大。

我们将创建一个类来处理这个用户InputController。我们称之为VirtualJoystickInputController,显然,它将扩展InputController

为了处理这个InputController的事件,我们将使用两个内部类。我们希望收听事件的每个视图一个:

public VirtualJoystickInputController(View view) {
  view.findViewById(R.id.vjoystick_main)
    .setOnTouchListener(new VJoystickTouchListener());
  view.findViewById(R.id.vjoystick_touch)
    .setOnTouchListener(new VFireButtonTouchListener());

  double pixelFactor = view.getHeight() / 400d;
  mMaxDistance = 50*pixelFactor;
}

mMaxDistance变量定义了我们认为用户的触摸距离达到最大值的距离。同样,该值以屏幕单位表示。你可以把最大距离想象成虚拟游戏手柄的半径。这个距离越小,操纵杆越灵敏。

一个小的最大距离将允许快速反应,而一个大的将允许更好的精度。随意试验它的大小,让它像你想要的那样工作。

Creating a virtual joystick

击键比虚拟操纵杆更容易操作。我们使用与前面示例相同的逻辑。当事件为向下动作时,将mIsFiring设置为true,当事件为向上动作时,将false设置为:

private class VFireButtonTouchListener implements View.OnTouchListener {
  @Override
  public boolean onTouch(View v, MotionEvent event) {
    int action = event.getActionMasked();
    if (action == MotionEvent.ACTION_DOWN) {
      mIsFiring = true;
    }
    else if (action == MotionEvent.ACTION_UP) {
      mIsFiring = false;
    }
    return true;
  }
}

虚拟操纵杆的监听器更有趣。当执行向下动作时,我们记录触摸的位置,当触摸上升时,我们也重置值。但是,只要它移动,我们就根据到原始触摸的距离更新mHorizontalFactormVerticalFactor的值:

private class VJoystickTouchListener implements View.OnTouchListener {
  @Override
  public boolean onTouch(View v, MotionEvent event) {
    int action = event.getActionMasked();
    if (action == MotionEvent.ACTION_DOWN) {
      mStartingPositionX = event.getX(0);
      mStartingPositionY = event.getY(0);
    }
    else if (action == MotionEvent.ACTION_UP) {
      mHorizontalFactor = 0;
      mVerticalFactor = 0;
    }
    else if (action == MotionEvent.ACTION_MOVE) {
      // Get the proportion to the max
      mHorizontalFactor = (event.getX(0) - mStartingPositionX) / mMaxDistance;
      if (mHorizontalFactor > 1) {
        mHorizontalFactor = 1;
      }
      else if (mHorizontalFactor < -1) {
        mHorizontalFactor = -1;
      }
      mVerticalFactor = (event.getY(0) - mStartingPositionY) / mMaxDistance;
      if (mVerticalFactor > 1) {
        mVerticalFactor = 1;
      }
      else if (mVerticalFactor < -1) {
        mVerticalFactor = -1;
      }
    }
    return true;
  }
}

请注意,我们希望将mHorizontalFactormVerticalFactor保持在-1 和 1 之间;因此,每当距离大于mMaxDistance时,我们就不考虑它了。

最后,是时候将这个新控制器连接到GameEngine了。很简单。我们只需要更新fragment_game.xml的布局,包括view_vjoystick.xml而不是view_keypad.xml,然后更新GameEngine的初始化:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical"
  android:paddingTop="@dimen/activity_vertical_margin"
  android:paddingLeft="@dimen/activity_horizontal_margin"
  android:paddingRight="@dimen/activity_horizontal_margin"
  tools:context="com.plattysoft.yass.counter.GameFragment">

  <TextView
    android:layout_gravity="top|left"
    android:id="@+id/txt_score"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello_world" />

  <Button
    android:layout_gravity="top|right"
    android:id="@+id/btn_play_pause"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/pause" />

  <include layout="@layout/view_vjoystick" />

</FrameLayout>

提醒一下,GameEngine的初始化是在GameFragmentonViewCreated内完成的。我们只需要创建一个适当的InputController实例:

mGameEngine = new GameEngine(getActivity());
mGameEngine.setInputController(new 
 VirtualJoystickInputController(get View()));
mGameEngine.addGameObject(new Player(getView()));
mGameEngine.startGame();

是时候运行游戏,试试这个控制器了。

一般考虑和改进

这种输入法是对我们之前使用的基本键盘的巨大改进。触摸区域和屏幕一样大,玩家不需要看屏幕的这个区域就可以点击小按钮。它会在任何地方工作。

这个系统处理对角线,水平和垂直运动,以及任何介于两者之间的运动。

玩家不需要从屏幕上移开他/她的手指来改变方向。

缺乏视觉反馈,玩家在使用虚拟游戏手柄时,把它画成两个圆圈就可以解决。一个大圆将显示虚拟操纵杆的范围,而一个较小的圆将显示当前触摸指针。另一方面,你可能不想,因为没有视觉混乱会使屏幕更干净。

物理控制器

是时候进入铁杆游戏玩家喜欢的控制器类型了:物理控制器。

有一些设备将控制器作为硬件的一部分。一些值得注意的例子是 XPeria Play——有滑动游戏手柄的先锋手机之一——和 Nvidia Shield,这是这一类别的最新产品。

XPeria Play 是首批集成游戏手柄的设备之一:

Physical controllers

Nvidia Shield 是功能最强大的安卓设备之一,具有游戏手柄:

Physical controllers

另一方面,有许多品牌为智能手机制造游戏控制器,它们在传统游戏玩家中相当受欢迎。所有这些都是蓝牙控制器,可以连接到您的手机或平板电脑。其中一些是为了让你的手机适合它而设计的就像 Gametel(另一个先锋)和大多数 MOGA 型号一样。

Physical controllers

带可调节条的 MOGA 控制器,可容纳您的手机

也有一些安卓设备使用控制器作为主要输入源。在这里,我们谈论的是像 OUYA 这样的微型计算机,或者像亚马逊 FireTV 或安卓电视这样的类似电视的设备。

Physical controllers

OUYA 是第一个安卓微型计算机

这些设备的工作方式与高强度气体放电设备非常相似,要么是键盘形式,要么是基于轴的方向控制(模拟操纵杆)。我们要做的就是设置正确的监听器。

有些控制器确实有自己的专有库。我们不讨论这个,因为它们非常具体,并且提供了如何集成它们的详细文档。MOGA Pocket 就是这种情况(更高级的 MOGA 控制器支持两种模式:专有和 HID)。

大多数控制器作为 HID 工作,这是标准配置。

我们可以在Activity级别或View级别为控制器设置监听器。无论如何,我们都需要扩展这个类。没有办法为这些方法添加侦听器,它们必须被重写。既然我们已经扩展了Activity类,我们就这样做。

我们听活动里面的KeyEventMotionEvent

我们需要倾听两种类型的事件。它们如下:

  • KeyEvent:对于所有的按钮按压,在一些游戏手柄中,还有方向交叉
  • MotionEvent:与沿轴运动相关的事件:操纵杆

我们希望将输入控制器从Activity中分离出来,所以我们将制作一个特殊的监听器,将我们需要的两个事件组合起来,然后在上面制作Activity委托。

我们需要的界面很简单,我们就称之为GamepadControllerListener:

public interface GamepadControllerListener {

  boolean dispatchGenericMotionEvent(MotionEvent event);

  boolean dispatchKeyEvent(KeyEvent event);
}

Activity内部,我们创建一个方法来设置GamepadControllerListener类型的监听器。因为我们一次只需要一个侦听器,所以设置了方法而不是 add。要删除侦听器,我们只需要将其设置为 null:

public void setGamepadControllerListener(GamepadControllerListener listener) {
  mGamepadControllerListener = listener;
}

最后,我们必须覆盖我们的Activity内部的 dispatchGenericMotionEventdispatchKeyEvent:

@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
  if (mGamepadControllerListener != null) {
    if (mGamepadControllerListener.dispatchGenericMotionEvent(event)) {
      return true;
    }
  }
  return super.dispatchGenericMotionEvent(event);
}

@Override
public boolean dispatchKeyEvent (KeyEvent event) {
  if (mGamepadControllerListener != null) {
    if (mGamepadControllerListener.dispatchKeyEvent(event)) {
      return true;
    }
  }
  return super.dispatchKeyEvent(event);
}

请注意,此方法使用的约定是:如果事件被消费,则返回 true 如果事件没有被消费,则返回 false。在我们的例子中,只有当事件被侦听器消费时,我们才会返回 true。在其他情况下,我们将返回结果,将事件委托给基类。

在超类中调用对应的方法非常重要,因为Activity类内部做了很多处理,我们不想不小心丢弃。

有了这些组件,我们可以继续创建我们的GamepadInputController,这将扩展InputController并实现GamepadControllerListener:

public class GamepadInputController
  extends InputController
  implements GamepadControllerListener {

  public GamepadInputController(YassActivity activity) {
    mActivity = activity;
  }

  @Override
  public void onStart() {
    mActivity.setGamepadControllerListener(this);
  }

  @Override
  public void onStop() {
    mActivity.setGamepadControllerListener(null);
  }

  [...]
}

钩住Activity的关键和运动事件是我们想要尽可能限制的。这就是为什么我们忽略InputControlleronStoponStart方法,只在游戏运行时设置监听器。

有几种可能的控制器布局。一般来说,它们通常具有交叉控制和/或模拟操纵杆。有些有几个模拟操纵杆,当然还有按钮。总的来说,有一些关于事件的重要细节,以及如何配置不同的游戏手柄:

Physical controllers

  • 交叉控制可以由按钮组成,也可以是另一个模拟操纵杆。如果它有按钮,它将作为KeyEvent处理,常数与 D-Pad 相同。如果是模拟操纵杆,将使用AXIS_HAT_XAXIS_HAT_Y
  • 模拟操纵杆通过MotionEvent操作,我们可以使用MotionEventgetAxisValue方法读取它们。默认操纵杆将使用AXIS_XAXIS_Y
  • 我们不打算为这个游戏绘制第二个模拟操纵杆,但是它被绘制在AXIS_ZAXIS_RZ上。
  • 按钮被映射为带有每个按钮名称的KeyEvent

处理运动事件

当我们收到 a MotionEvent时,我们需要首先确认事件来自我们应该阅读的来源。

源是事件的一部分,是标志的组合。我们感兴趣的是:

  • SOURCE_GAMEPAD:表示设备有 ABXY 等游戏手柄按钮。
  • SOURCE_DPAD:表示设备有 D-Pad。
  • SOURCE_JOYSTICK:表示设备有模拟控制杆。

我们应该处理的唯一运动事件是信号源设置了操纵杆标志的事件。游戏手柄和 D-Pad 信号源都将作为KeyEvent发送。

接收MotionEvent的处理如下:

@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
  int source = event.getSource();

  if ((source & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) {
    return false
  }
  mHorizontalFactor = event.getAxisValue(MotionEvent.AXIS_X);
  mVerticalFactor = event.getAxisValue(MotionEvent.AXIS_Y);

  InputDevice device = event.getDevice();
  MotionRange rangeX = device.getMotionRange(MotionEvent.AXIS_X, source);
  if (Math.abs(mHorizontalFactor) <= rangeX.getFlat()) {
    mHorizontalFactor = event.getAxisValue(MotionEvent.AXIS_HAT_X);
    MotionRange rangeHatX = device.getMotionRange(MotionEvent.AXIS_HAT_X, source);
    if (Math.abs(mHorizontalFactor) <= rangeHatX.getFlat()) {
      mHorizontalFactor = 0;
    }
  }
  MotionRange rangeY = device.getMotionRange(MotionEvent.AXIS_Y, source);
  if (Math.abs(mVerticalFactor) <= rangeY.getFlat()) {
    mVerticalFactor = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
    MotionRange rangeHatY = device.getMotionRange(MotionEvent.AXIS_HAT_Y, source);
    if (Math.abs(mVerticalFactor) <= rangeHatY.getFlat()) {
      mVerticalFactor = 0;
    }
  }
  return true;
}

首先,我们检查来源。如果不是来自操纵杆,我们只返回false,因为我们不会消费这个事件。

然后我们读取MotionEvent.AXIS_XMotionEvent.AXIS_Y的轴值,并将其分配给我们的变量。这意味着读取默认操纵杆。但是我们还没有结束。控制器可能有一个作为模拟操纵杆的十字。

为了决定我们是否读取辅助操纵杆,我们检查默认操纵杆上是否有输入。如果没有,我们将第二个变量的值赋给我们的变量。

需要注意的是,大多数模拟操纵杆在 0 处没有完全对齐,因此将mHorizontalFactormVerticalFactor的值与 0 进行比较不是检测操纵杆是否移动的有效方法。

模拟操纵杆不是完全以 0 为中心。

我们需要做的是读取设备运动范围的平面值。这比听起来要简单得多,因为所有这些信息都是MotionEvent的一部分。

然后,如果没有来自默认轴的输入,我们将AXIS_HAT_XAXIS_HAT_Y的值分配给我们的变量。我们还检查轴的输入是否高于其平坦值,如果不是,则将其设置为 0。我们需要这样做,否则飞船将在没有任何输入的情况下缓慢移动。

最后,我们返回true表示我们已经消费了事件。

处理关键事件

dispatchKeyEvent的实现与我们对屏幕上带有按钮的基本控制器的实现非常相似:

@Override
public boolean dispatchKeyEvent(KeyEvent event) {
  int action = event.getAction();
  int keyCode = event.getKeyCode();
  if (action == MotionEvent.ACTION_DOWN) {
    if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
      mVerticalFactor -= 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
      mVerticalFactor += 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
      mHorizontalFactor -= 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
      mHorizontalFactor += 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_BUTTON_A) {
      mIsFiring = true;
      return true;
    }
  }
  else if (action == MotionEvent.ACTION_UP) {
    if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
      mVerticalFactor += 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
      mVerticalFactor -= 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
      mHorizontalFactor += 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
      mHorizontalFactor -= 1;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_BUTTON_A) {
      mIsFiring = false;
      return true;
    }
    else if (keyCode == KeyEvent.KEYCODE_BUTTON_B) {
      mActivity.onBackPressed();
      return true;
    }
  }
  return false;
}

唯一显著的区别是,我们将键码与数字键盘的常数进行比较,而不是视图标识。但除此之外,逻辑完全一样。

我们还必须注意映射按钮 B 来充当返回键。虽然这已经在最新版本的安卓上完成了,但情况并不总是这样,所以我们需要处理它。为此,我们使用已经在YassActivity中创建的onBackPressed回调。

同样,在 Android 4.2 (API 等级 17)及之前,系统默认将BUTTON_A作为 Android 回车键处理。这也是为什么我们要一直用BUTTON_A作为首要游戏动作的原因。

检测游戏手柄

当我们启动游戏时,检查控制器是否连接是一个很好的做法。这允许我们在用户开始玩之前显示如何使用控制器的帮助屏幕。我们还应该检查控制器是否在游戏已经运行时断开连接,以暂停游戏。

虽然检查控制器可以通过InputDevice类来完成,但是检查控制器的变化只是在 API 级别 16 中引入的(我们使用的是 minSDK=15)。

检测控制器连接或断开的变化只在果冻豆中引入。

我们不会提供向后兼容的解决方案来检测控制器的连接和断开。如果需要做的话,在http://developer . Android . com/training/game-controller/compatibility . html的官方文档中有详细的步骤;这些基本上在输入设备上使用轮询机制,并检查列表中的变化。

我们将在MainMenuFragmentonResume期间检查游戏手柄。第一次检测到控制器时,我们会显示一个AlertDialog显示如何使用游戏手柄:

@Override
public void onResume() {
  super.onResume();
  if (isGameControllerConnected() && shouldDisplayGamepadHelp()) {
    displayGamepadHelp();
    // Do not show the dialog again
    PreferenceManager.getDefaultSharedPreferences(getActivity())
      .edit()
      .putBoolean(PREF_SHOULD_DISPLAY_GAMEPAD_HELP, false)
      .commit();
  }
}

private boolean shouldDisplayGamepadHelp() {
  return PreferenceManager.getDefaultSharedPreferences(getActivity())
    .getBoolean(PREF_SHOULD_DISPLAY_GAMEPAD_HELP, true);
}

我们使用默认的共享首选项来存储我们是否已经显示了对话框。显示后,我们将该值设置为 false,因此不再显示。

检查是否连接了控制器的方法如下:

public boolean isGameControllerConnected() {
  int[] deviceIds = InputDevice.getDeviceIds();
  for (int deviceId : deviceIds) {
    InputDevice dev = InputDevice.getDevice(deviceId);
    int sources = dev.getSources();
    if (((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) ||
        ((sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK)) {
      return true;
    }
  }
  return false;
}

我们遍历输入设备,如果其中任何一个的来源是游戏手柄或操纵杆,我们返回 true。

如果没有找到这些来源的设备,我们返回 false。

注意每个InputDevice也有名字。如果你想显示不同的帮助屏幕,比如英伟达盾牌,这对于识别特定的游戏手柄非常有用。

为了检查控制器在游戏过程中是否断开,我们需要在InputManager上注册一个InputDeviceListener并处理事件。我们将使GameFragment实施InputDeviceListener

我们在GameEngine创建后立即注册,在onDestroy停止游戏后取消注册。您需要添加一些注释来防止 int 给出一个在最小 SDK 上不可用的方法的错误,或者将其包装到一个检查版本的if块中,就像我们之前做的那样。

然后,就像在设备断开连接时暂停游戏一样简单:

@Override
public void onInputDeviceRemoved(int deviceId) {
  if (!mGameEngine.isRunning()) {
    pauseGameAndShowPauseDialog();
  }
}

请注意,当任何设备断开连接时,游戏会暂停。不是控制器的设备不太可能断开连接,但我们可以通过检查信号源来确定它是控制器,就像我们对isGameControllerConnected所做的那样。

传感器和输入控制器

传感器是智能手机上控制游戏的常见方式。当游戏中唯一的控件是左和右时,它们工作得很好(就像赛车游戏一样)。如果你打算也上下移动,你需要要求玩家在游戏开始时做一个校准,使其可用。请注意,当您只使用一个轴时,这种校准是不必要的。

除此之外,上下运动会干扰sensorLandscape方向。所以,对 YASS 来说,使用传感器不是一个好主意。

只有在某些情况下,传感器才是好的控制器。

您还必须考虑到,虽然传感器是方向的替代品,但您仍然需要将操作按钮放在屏幕上——在我们的例子中,是消防按钮。

我们不会为 YASS 使用传感器,但是,如果你想制作一个使用它们的游戏,我们将涵盖基础知识。

您需要为加速度计注册一个监听器,为磁场注册另一个监听器。您应该只在游戏运行时监听传感器,因此我们将覆盖生命周期方法来相应地注册和注销传感器:

private void registerListeners() {
  SensorManager sm = (SensorManager)
    mActivity.getSystemService(Activity.SENSOR_SERVICE);
  sm.registerListener(mAccelerometerChangesListener,
    sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER),
    SensorManager.SENSOR_DELAY_FASTEST);
  sm.registerListener(mMagneticChangesListener,
    sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD),
    SensorManager.SENSOR_DELAY_FASTEST);
}

private void unregisterListeners() {
  SensorManager sm = (SensorManager)
    mActivity.getSystemService(Activity.SENSOR_SERVICE);
  sm.unregisterListener(mAccelerometerChangesListener);
  sm.unregisterListener(mMagneticChangesListener);
}

@Override
public void onStart() {
  registerListeners();
}
@Override
public void onStop() {
  unregisterListeners();
}
@Override
public void onResume() {
  registerListeners();
}
@Override
public void onPause() {
  unregisterListeners();
}

请注意,我们正在使用SensorManager.SENSOR_DELAY_FASTEST,这意味着传感器将尽可能快、尽可能频繁地给出反馈。这对实时游戏非常重要。

我们将对象设置为监听器。每个监听器将传感器的值复制到一个本地数组中,稍后我们将对其进行处理。例如,对于加速度计,我们将执行以下操作:

@Override
public void onSensorChanged(SensorEvent event) {
  System.arraycopy(event.values, 0, mLastAccels, 0, 3);
}

为了获得最终值,我们必须做一些计算。因此,我们将在调用onUpdate之前添加一个onPreUpdate方法,该方法将由GameEngine调用。

需要注意的是,有一些特殊情况。它们如下:

  • 有些设备没有磁场传感器。在这种情况下,我们可以使用加速度计值的简化版本。Nvidia Shield 和特定版本的 Nook 就是其中的一些设备。
  • 在所有情况下,传感器都与设备的默认方向相关,可以是横向或纵向。在处理这些值时,我们必须考虑到这一点。

总之,横轴的转换可以这样完成:

private double getHorizontalAxis() {
  if (SensorManager.getRotationMatrix(mRotationMatrix, null, mLastAccels, mLastMagFields)) {
    if (mRotation == Surface.ROTATION_0) {
      SensorManager.remapCoordinateSystem(mRotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, mRotationMatrix);
      SensorManager.getOrientation(mRotationMatrix, mOrientation);
      return mOrientation[1] * DEGREES_PER_RADIAN;
    }
    else {
      SensorManager.getOrientation(mRotationMatrix, mOrientation);
      return -mOrientation[1] * DEGREES_PER_RADIAN;
    }
  }
  else {
    // Case for devices which do NOT have magnetic sensors
    if (mRotation == Surface.ROTATION_0) {
      return -mLastAccels[0]* 5;
    }
    else {
      return -mLastAccels[1] * -5;
    }
  }
}

getHorizontalAxis代码执行以下步骤:

  • 使用加速度计和磁传感器的最新数据计算旋转矩阵。
  • 如果返回真,一切顺利。根据设备的旋转,我们决定是否需要重新映射坐标系,然后返回转换为度数的方向。
  • 如果无法计算(缺少磁场传感器),该方法返回 false。我们必须依靠加速度计值的近似值。根据设备的旋转,我们应该使用一个或另一个轴。

设备的旋转可以在InputController的构造器中用一行代码读取。

mRotation = yassActivity.getWindowManager().getDefaultDisplay().getRotation();

最后,onPreUpdate法:

@Override
public void onPreUpdate() {
  mHorizontalFactor = getHorizontalAxis()/ MAX_ANGLE;
  if (mHorizontalFactor > 1) {
    mHorizontalFactor = 1;
  }
  else if (mHorizontalFactor < -1) {
    mHorizontalFactor = -1;
  }
  mVerticalFactor = 0;
}

is 方法只是通过使用我们认为它完全倾斜的最大角度,将读数(以度为单位)转换为[-1,1]范围内的值。我建议你玩这个常数,从 30 度开始。

关于搬运传感器的更多信息,可以查看官方文档

选择控制模式

游戏要求用户选择自己喜欢的控制模式是很常见的,但不要求什么是不必要的,尽可能避免摩擦也是一种很好的做法。

YASS 只使用了一个虚拟操纵杆和游戏手柄控制。没有必要问用户他或她想要哪一个。两种输入模式都是兼容的,尤其是因为虚拟操纵杆在不使用时不会在屏幕上显示任何内容。我们唯一需要做的就是修改GameEngine来支持多个InputController

我们将同时支持两种输入模式。

同时支持两种输入模式的方法是创建一个CompositeInputController,使用合成模式同时拥有一个VirtualJoystickInputController和一个GamepadInputController,并组合来自两者的输入。

为了同步来自两个输入控制器的读数,我们将在InputController上使用名为onPreUpdate的方法,该方法将在onUpdate之前调用。我们将使用从其他控制器读取的值来填充mHorizontalFactormVerticalFactormIsFiring的值。

public void onPreUpdate() {
  mIsFiring = mGamepadInputController.mIsFiring || mVJoystickInputController.mIsFiring;
  mHorizontalFactor = mGamepadInputController.mHorizontalFactor + mVJoystickInputController.mHorizontalFactor;
  mVerticalFactor = mGamepadInputController.mVerticalFactor + mVJoystickInputController.mVerticalFactor;
}

我们现在有了一个可以用虚拟操纵杆和游戏手柄控制的游戏。

总结

我们已经学习了如何以多种方式处理用户输入,以及如何使其对GameEngine透明。

为了从控制器获得适当的视觉反馈,我们创建了一个Player游戏对象,该对象根据来自InputController的值更新其位置。我们还学习了如何在玩游戏时在GameEngine中添加和移除游戏对象。

我们创造了一个非常基本的键盘,后来演变成了一个虚拟操纵杆。我们还学习了如何处理外部控制器。

在这一点上,我们的游戏有一个沿着屏幕移动并发射子弹的宇宙飞船。它可以使用虚拟操纵杆或游戏手柄独立控制。

当前的实现确实偶尔会滞后,我们几乎还没有开始在屏幕上绘制对象。是时候解决这个问题了。下一步:通过直接在视图上绘制来改进渲染,而不是依赖于在屏幕上定位视图。