二十二、粒子系统和处理屏幕触摸

我们已经有了我们在上一章中使用线程实现的实时系统。在这一章中,我们将创建将在这个实时系统中存在和发展的实体,就好像它们有自己的思想一样。

我们还将通过学习如何设置与屏幕交互的能力来了解用户如何将这些实体绘制到屏幕上。这不同于在 UI 布局中与小部件交互。

这一章的内容如下:

  • 向屏幕添加自定义按钮
  • 编码Particle
  • 编码ParticleSystem
  • 处理屏幕触摸

我们将从向我们的应用添加自定义用户界面开始。

在屏幕上添加自定义按钮

我们需要让用户控制何时开始另一个绘图,并清除他们之前工作的屏幕。我们还需要用户能够决定是否或何时将该绘图变为现实。为了实现这一点,我们将在屏幕上添加两个按钮,每个按钮对应一个任务。

将这些新属性添加到LiveDrawingView类中其他属性之后的代码中:

// These will be used to make simple buttons
private var resetButton: RectF
private var togglePauseButton: RectF

我们现在有两个RectF实例。这些对象各有四个Float坐标,我们提出的两个按钮的每个角各有一个坐标。

我们现在将向LiveDrawingView类添加一个init块,并在首次创建LiveDrawingView实例时初始化位置,如下所示:

init {
   // Initialize the two buttons
   resetButton = RectF(0f, 0f, 100f, 100f)
   togglePauseButton = RectF(0f, 150f, 100f, 250f)
}

现在我们已经为按钮添加了实际坐标。如果你在屏幕上可视化坐标,那么你会看到它们在左上角,暂停按钮在重置/清除按钮的正下方。

现在我们可以画按钮了。将以下两行代码添加到LiveDrawingView类的draw函数中。预先存在的注释准确地显示了新的突出显示的代码应该放在哪里:

// Draw the buttons
canvas.drawRect(resetButton, paint)
canvas.drawRect(togglePauseButton, paint)

新代码使用了drawRect函数的覆盖版本,我们只需将两个RectF实例直接传递到通常的Paint实例旁边。我们的按钮现在将出现在屏幕上。

我们将在本章后面看到用户如何与这些稍微粗糙的按钮交互。

实现粒子系统效果

粒子系统是一个控制粒子的系统。在我们的例子中,ParticleSystem是我们将要编写的一个类,它将产生Particle类(也是我们将要编写的一个类)的实例(大量实例),这些实例将一起创建一个简单的类似爆炸的效果。

这是由粒子系统控制的一些粒子的截图,可能会在本章结束时出现:

Implementing a particle system effect

为了澄清,每个彩色方块都是Particle类的一个实例,所有Particle实例都由ParticleSystem类控制和持有。此外,用户将通过用手指绘图来创建多个(数百个)ParticleSystem实例。粒子系统将以点或块的形式出现,直到用户点击暂停按钮,它们才会出现。我们将仔细检查代码,以便您能够在代码中修改ParticleParticleSystem实例的大小、颜色、速度和数量。

留给读者的练习是在屏幕上添加额外的按钮,以允许用户将这些属性作为应用的一项功能进行更改。

我们将从编码Particle类开始。

对粒子类进行编码

添加如下代码所示的 import语句、成员变量、构造函数和init块:

import android.graphics.PointF

class Particle(direction: PointF) {

    private val velocity: PointF = PointF()
    val position: PointF = PointF()

    init {
          // Determine the direction
          velocity.x = direction.x
          velocity.y = direction.y
    }

我们有两个性质——一个是速度性质,一个是位置性质。他们都是PointF对象。PointF持有两种Float价值观。粒子的位置很简单:它只是一个水平和垂直的值。速度值得多解释一下。velocity对象PointF中的两个值都是速度,一个水平,另一个垂直。这两种速度的结合将创造一个方向。

接下来,增加如下update功能;我们稍后将更详细地了解它:

fun update() {
    // Move the particle
    position.x += velocity.x
    position.y += velocity.y
}

每个Particle实例的update函数将由ParticleSystem 对象的update函数为应用的每一帧调用,该函数又将由LiveDrawingView类调用(同样是在update函数中),我们将在本章后面对其进行编码。

update功能中,position的水平和垂直值使用velocity的相应值进行更新。

类型

请注意,我们不会在更新中使用当前的帧速率。如果你想确定你的粒子都以正确的速度飞行,你可以修改这个,但是所有的速度都是随机的。添加这个额外的计算(对于每个粒子)并没有太大的好处。然而,正如我们将很快看到的那样,ParticleSystem类将需要考虑每秒的当前帧数来测量它应该运行多长时间。

现在我们可以进入ParticleSysytem课了。

对粒子系统类进行编码

ParticleSystem类比Particle类多了一些细节,但还是相当直白。记住我们需要通过这个类实现什么:保持、繁殖、更新和绘制一堆(相当大的一堆)Particle实例。

添加以下构造函数、属性和导入语句:

import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF

import java.util.*

class ParticleSystem {

    private var duration: Float = 0f
    private var particles: 
         ArrayList<Particle> = ArrayList()

    private val random = Random()
    var isRunning = false

我们有四个属性:第一,一个名为durationFloat,它将被初始化为我们希望效果运行的秒数;名为particlesArrayList实例保存Particle实例,并将保存我们为此系统实例化的所有Particle对象。

创建名为randomRandom实例是因为我们需要生成如此多的随机值,以至于每次创建一个新对象都会让我们慢一点。

最后,名为isRunningBoolean将跟踪粒子系统当前是否正在显示(更新和绘制)。

现在我们可以对initParticles功能进行编码了。每次我们想要一个新的ParticleSystem时都会调用这个函数。请注意,唯一的参数是名为numParticlesInt

当我们调用initParticles时,我们可以从初始化疯狂数量的粒子中获得一些乐趣。如下添加initParticles函数,然后我们将更仔细地查看代码:

fun initParticles(numParticles:Int){

   // Create the particles
   for (i in 0 until numParticles) {
         var angle: Double = random.nextInt(360).toDouble()
         angle *= (3.14 / 180)

         // Option 1 - Slow particles
         val speed = random.nextFloat() / 3

         // Option 2 - Fast particles
         //val speed = (random.nextInt(10)+1);

         val direction: PointF

         direction = PointF(Math.cos(
                     angle).toFloat() * speed,
                     Math.sin(angle).toFloat() * speed)

         particles.add(Particle(direction))

    }
}

initParticles函数只包含一个完成所有工作的for循环。for循环从零运行到numParticles

首先,生成一个 0 到 359 之间的随机数并存储在Float angle中。接下来,有一点数学,我们用3.14/180乘以angle。这会将中的角度转换为基于弧度的测量,这是我们稍后将使用的Math类所要求的。

然后我们生成另一个介于 1 和 10 之间的随机数,并将结果分配给一个名为speedFloat变量。

请注意,我已经添加了注释,为这部分代码中的值建议了不同的选项。我在ParticleSystem课的几个地方都是这样做的,当我们到了这一章的最后,我们会有一些乐趣改变这些值,看看这对绘图应用有什么影响。

现在我们有了一个随机的角度和速度,我们可以将它们转换并组合成一个向量,可以在每一帧的update函数内部使用。

矢量是决定方向和速度的值。我们的向量存储在direction对象中,直到它被传递到Particle构造函数中。向量可以是多维的。我们的由两个维度组成,因此定义了 0 到 359 度之间的航向和 1 到 10 度之间的速度。你可以在我的网站上阅读更多关于向量、标题、正弦和余弦的信息。

我决定不解释使用Math.sinMath.cos来完整创建向量的单行代码,因为魔法部分出现在以下公式中:

  • 角度的余弦 x speed
  • 角度 x 的正弦值speed

其余的魔法发生在由Math类提供的余弦和正弦函数中的隐藏计算中。如果你想知道他们的全部细节,那么你可以看看前面的提示。

最后,一个新的Particle被创建,然后被添加到particles ArrayList中。

接下来,我们将对update功能进行编码。注意update功能需要当前帧率作为参数。将update功能编码如下:

fun update(fps: Long) {
   duration -= 1f / fps

   for (p in particles) {
         p.update()
  }

   if (duration < 0) {
         isRunning = false
  }
}

update功能内发生的第一件事是从duration中取出经过的时间。记住fps的意思是每秒帧数,所以1/fps给出的值是一秒的几分之一。

接下来,有一个for循环,为particles ArrayList中的每个Particle实例调用update函数。

最后,代码检查粒子效果是否已经随着if(duration < 0)运行,如果已经运行,那么isRunning被设置为false

现在我们可以对emitParticles函数进行编码,该函数将设置每个Particle实例运行,不要与initParticles混淆,后者创建所有新粒子并给出它们的速度。initParticles功能将在用户与屏幕交互之前调用一次,而emitParticles功能将在用户在屏幕上绘制时每次需要启动效果时调用。

使用以下代码添加emitParticles功能:

fun emitParticles(startPosition: PointF) {
    isRunning = true

    // Option 1 - System lasts for half a minute
    duration = 30f

    // Option 2 - System lasts for 2 seconds
    //duration = 3f

    for (p in particles) {
          p.position.x = startPosition.x
          p.position.y = startPosition.y
   }
}

首先,注意所有粒子将开始的PointF作为参数被传入。所有的粒子都将从同一个位置开始,然后根据各自的随机速度扇出每一帧。

isRunning Boolean设置为true``duration设置为30f,效果运行 30 秒,for循环将每个粒子的位置设置为起始坐标。

我们的ParticleSysytem的最后一个功能是draw功能,它将揭示出效果的全部荣耀。该函数接收到对CanvasPaint的引用,因此它可以绘制到LiveDrawingView刚刚锁定在其draw函数中的同一个Canvas实例。

添加draw功能如下:

fun draw(canvas: Canvas, paint: Paint) {

    for (p in particles) {

           // Option 1 - Colored particles
           //paint.setARGB(255, random.nextInt(256),
           //random.nextInt(256),
           //random.nextInt(256))

           // Option 2 - White particles
           paint.color = Color.argb(255, 255, 255, 255)
           // How big is each particle?

           // Option 1 - Big particles
           //val sizeX = 25f
           //val sizeY = 25f

           // Option 2 - Medium particles
           //val sizeX = 10f
           //val sizeY = 10f

           // Option 3 - Tiny particles
           val sizeX = 12f
           val sizeY = 12f

           // Draw the particle
           // Option 1 - Square particles
           canvas.drawRect(p.position.x, p.position.y,
                       p.position.x + sizeX,
                       p.position.y + sizeY,
                       paint)

          // Option 2 - Circular particles
          //canvas.drawCircle(p.position.x, p.position.y,
          //sizeX, paint)
   }
}

在前面的代码中,一个for循环遍历particles中的每个Particle实例。设置好矩形的大小和颜色后,依次使用drawRect绘制每个Particle

请再次注意我是如何为代码更改建议不同的选项的,以便我们在完成编码后可以享受一些乐趣。

我们现在可以开始让粒子系统工作了。

在 LiveDrawingView 类中生成粒子系统

添加一个充满系统和更多成员的ArrayList实例来跟踪事物。将以下代码中突出显示的代码添加到现有注释指示的位置:

// The particle systems will be declared here later
private val particleSystems = ArrayList<ParticleSystem>()

private var nextSystem = 0
private val maxSystems = 1000
private val particlesPerSystem = 100

我们现在可以跟踪多达 1000 个粒子系统,每个系统中有 100 个粒子。随意玩这些数字。在现代设备上,你可以毫无困难地将粒子运行到数百万个,但是在模拟器上,它将开始与仅仅几十万个粒子作斗争。

通过添加以下突出显示的代码,初始化init块中的系统:

init {

  // Initialize the two buttons
  resetButton = RectF(0f, 0f, 100f, 100f)
  togglePauseButton = RectF(0f, 150f, 100f, 250f)

  // Initialize the particles and their systems
  for (i in 0 until maxSystems) {
 particleSystems.add(ParticleSystem())
 particleSystems[i]
 .initParticles(particlesPerSystem)
 }
}

代码循环通过ArrayList,在每个ParticleSystem实例上调用后跟initParticles的构造函数。

现在我们可以通过将高亮显示的代码添加到update函数来更新循环的每一帧上的系统:

private fun update() {
  // Update the particles
  for (i in 0 until particleSystems.size) {
 if (particleSystems[i].isRunning) {
 particleSystems[i].update(fps)
         }
 }
}

前面的代码循环遍历每个ParticleSystem实例,首先检查它们是否处于活动状态,然后调用update函数,每秒传入当前帧。

现在,我们可以通过将以下代码片段中突出显示的代码添加到draw函数来绘制循环的每一帧中的系统:

// Choose the font size
paint.textSize = fontSize.toFloat()

// Draw the particle systems
for (i in 0 until nextSystem) {
 particleSystems[i].draw(canvas, paint)
}

// Draw the buttons
canvas.drawRect(resetButton, paint)
canvas.drawRect(togglePauseButton, paint)

之前的代码循环通过particleSystems,在每个上面调用draw函数。当然,我们实际上还没有产生任何实例;为此,我们需要学习如何应对屏幕交互。

处理触摸

要让开始屏幕交互,将OnTouchEvent功能添加到LiveDrawingView类,如下所示:

override fun onTouchEvent(
   motionEvent: MotionEvent): Boolean {

   return true
}

这是一个被覆盖的函数,每次用户与屏幕交互时,安卓都会调用它。看看onTouchEvent唯一的参数。

原来motionEvent里面藏着一大堆数据,这些数据包含了刚刚发生的触摸的细节。操作系统将它发送给我们,因为它知道我们可能会需要一些。

注意,我说的是其中一些MotionEvent类相当广泛;它包含几十个函数和属性。

目前,我们需要知道的是,在玩家手指移动、触摸屏幕或被移除的精确时刻,屏幕会做出反应。

我们将使用的包含在motionEvent中的一些变量和函数包括:

  • action属性,不出所料,它保存已执行的动作。不幸的是,它以稍微编码的格式提供了这些信息,这解释了为什么需要一些其他变量。
  • ACTION_MASK变量,它提供了一个被称为掩码的值,借助更多一点的 Kotlin 技巧,可以用来过滤来自action的数据。
  • ACTION_UP变量,我们可以用它来查看所执行的动作(比如移除手指)是否是我们想要响应的动作。
  • ACTION_DOWN变量,我们可以用它来查看执行的动作是否是我们想要响应的动作。
  • ACTION_MOVE变量,我们可以用它来查看执行的动作是否是移动/拖动动作。
  • x属性保存事件发生的水平浮点坐标。
  • y属性保存事件发生的垂直浮点坐标。

作为的具体例子,假设我们需要使用ACTION_MASK过滤action中的数据,看看结果是否与ACTION_UP相同。如果是的话,那么我们知道用户刚刚将手指从屏幕上移开,可能是因为他们刚刚点击了一个按钮。一旦我们确定事件属于正确的类型,我们将需要使用xy找出事件发生的地点。

还有最后一个复杂因素。我提到的 Kotlin 诡计是&逐位运算符,不要与我们一直在结合if关键字使用的逻辑&&运算符混淆。

&按位运算符检查两个值中的每个对应部分是否为真。这是配合action使用ACTION_MASK时需要的过滤器。

健全性检查:我不太愿意详细讨论MotionEvent和按位运算符。有可能完成整本书,甚至制作一个专业质量的互动应用,而不需要完全理解它们。如果您知道我们将在下一节中编写的代码行决定了玩家触发的事件类型,那么这就是您需要知道的全部。我只是认为像你这样有眼光的读者会想知道这个系统是如何工作的。总之,如果你懂按位运算符,那就太好了;你可以走了。如果没有,也没关系;你还是可以去的。如果你对按位运算符感到好奇(有很多),你可以在https://en.wikipedia.org/wiki/Bitwise_operation了解更多。

现在我们可以对onTouchEvent功能进行编码,看到所有MotionEvent的东西都在运行。

对 onTouchEvent 函数进行编码

响应用户在屏幕上移动手指,在onTouchEvent函数中添加以下代码片段中突出显示的代码到我们已经有的代码中:

// User moved a finger while touching screen
if (motionEvent.action and MotionEvent.
 ACTION_MASK == 
 MotionEvent.ACTION_MOVE) {

 particleSystems[nextSystem].emitParticles(
 PointF(motionEvent.x,
 motionEvent.y))

 nextSystem++
 if (nextSystem == maxSystems) {
 nextSystem = 0
 }
}

return true

if条件检查事件类型是否是用户移动手指。如果是,那么particleSystems中的下一个粒子系统的emitParticles函数被调用。之后,nextSystem变量递增,并进行测试,看它是否是最后一个粒子系统。如果是,那么nextSystem被设置为零,准备在下次需要时开始重用现有的粒子系统。

我们可以继续让系统对用户按下其中一个按钮做出响应,方法是在我们刚刚讨论的前一个代码之后和我们已经编码的return语句之前,在下面的代码片段中添加高亮显示的代码:

// Did the user touch the screen
if (motionEvent.action and MotionEvent.ACTION_MASK ==
 MotionEvent.ACTION_DOWN) {

 // User pressed the screen so let's 
 // see if it was in the reset button
 if (resetButton.contains(motionEvent.x,
 motionEvent.y)) {

 // Clear the screen of all particles
 nextSystem = 0
 }

 // User pressed the screen so let's 
 // see if it was in the toggle button
 if (togglePauseButton.contains(motionEvent.x,
 motionEvent.y)) {

 paused = !paused
 }
}

return true

if语句的条件检查用户是否点击了屏幕。如果有,RectF类的contains功能与xy一起使用,以查看该按钮是否在我们的自定义按钮内。如果按下复位按钮,那么当nextSystem设置为零时,所有的颗粒将消失。如果按下暂停按钮,则切换paused的值,使update功能停止/开始在线程内被调用。

完成抬头显示器

编辑printDebuggingText功能中的代码,如下所示:

canvas.drawText("Systems: $nextSystem",
         10f, (fontMargin + debugStart + 
         debugSize * 2).toFloat(), paint)

canvas.drawText("Particles: ${nextSystem * 
         particlesPerSystem}",
         10f, (fontMargin + debugStart 
         + debugSize * 3).toFloat(), paint)

前面的代码只是将一些有趣的统计数据打印到屏幕上,告诉我们当前绘制了多少粒子和系统。

运行应用

现在我们可以看到实时绘图应用在运行,并使用我们在代码中注释掉的一些不同选项。

用小的、圆的、彩色的、快速的粒子运行应用。下面的截图显示了在几个地方被点击的屏幕:

Running the app

然后恢复绘图,如下图截图所示:

Running the app

用小的、白色的、方形的、缓慢的、持续时间长的粒子做一个儿童风格的图画,如下图所示:

Running the app

然后恢复绘图,等待 20 秒,直到绘图恢复生机并发生变化:

Running the app

总结

在这一章中,我们学习了如何将数千个独立的实体添加到我们的实时系统中。这些实体由ParticleSystem类控制,该类又与游戏循环交互并受其控制。当游戏循环在一个线程中运行时,我们了解到用户仍然可以与屏幕无缝交互,操作系统将通过onTouchEvent功能向我们发送这些交互的细节。

在下一章中,当我们探索如何播放音效时,我们的应用最终会变得有点嘈杂。