Unity-2D-游戏开发教程-三-

151 阅读1小时+

Unity 2D 游戏开发教程(三)

原文:Developing 2D Games with Unity

协议:CC BY-NC-SA 4.0

七、角色、协程和出生点

这一章将看到我们构建一些对任何视频游戏都很重要的核心组件。我们将构建一个游戏管理器,负责协调和运行游戏逻辑,比如在玩家死亡时让她出生。我们还将构建一个摄像机管理器,以确保摄像机总是设置正确。我们将更深入地了解 Unity,并学习如何通过编程来做事情,而不是依赖 Unity 编辑器。从长远来看,以编程方式做事可以让你的游戏架构更加灵活,并节省你的时间。在本章中,你还会学到 C# 和 Unity 编辑器的一些有用的特性,它们会让你的生活更简单,代码更整洁。

创建游戏管理器

到目前为止,我们一直在创建游戏的一些片段,这些片段之间没有任何协调逻辑。我们将创建一个游戏管理器脚本或“类”,它将负责运行游戏逻辑,例如,如果玩家被敌人杀死,它将生成玩家。

一个

在我们开始编写 RPGGameManager 脚本之前,让我们先了解一种叫做 Singleton 的软件设计模式。在应用的生命周期内,应用需要创建一个且只有一个特定类的实例时,可以使用单例。当你有一个类提供游戏中其他几个类使用的功能时,比如在游戏管理器类中协调游戏逻辑,单例是很有用的。单例可以提供对这个类及其功能的公共统一访问点。它们还提供惰性实例化,这意味着它们是在第一次被访问时创建的。

在我们开始把单例看作游戏开发架构的救星之前,让我们先来看看单例的一些缺点。

尽管单例可以为功能提供统一的访问点,但这也意味着单例持有状态不确定的全局可访问值。整个游戏中的任何一段代码都可以访问和设置 Singleton 中的数据。虽然这看起来是件好事,但是想象一下,试图找出访问单例的 20 个不同类中的哪一个将特定的属性设置为不正确的值。那是噩梦的内容。

使用 Singleton 的另一个缺点是,我们很难控制 Singleton 实例化的精确时间。例如,假设我们的游戏正处于一段非常图形化的代码中,突然一个我们希望在游戏早期创建的单例被实例化了。游戏断断续续,影响最终用户的体验。

对于单身族,还有其他几个有争议的优点和缺点,你应该仔细阅读它们,并自己决定何时使用它们。如果谨慎使用,独生子女肯定会让你的生活更轻松。

将我们的 RPGGameManager 类实现为单例类是有意义的,因为在任何时候,我们只需要一个类来协调游戏逻辑。我们不会有任何性能问题,因为当场景加载时,我们正在访问和初始化 RPGGameManager。

每个单例都包含防止创建该单例的其他实例的逻辑,从而保持其作为单个唯一实例的状态。我们将在稍后创建 RPGGameManager 类时回顾其中的一些逻辑。

创建单例

在层级中创建一个新的游戏对象,重命名为:“RPGGameManager”。然后在脚本下创建一个名为“经理”的新文件夹。

创建一个名为“RPGGameManager”的新 C# 脚本,并将其移动到 Manager 文件夹中。将脚本添加到 RPGGameManager 对象中。

在 Visual Studio 中打开 RPGGameManager 脚本,并使用以下代码构建 RPGGameManager 类:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RPGGameManager : MonoBehaviour
{

// 1
    public static RPGGameManager sharedInstance = null;

    void Awake()
    {

// 2
        if (sharedInstance != null && sharedInstance != this)
        {
// 3
            Destroy(gameObject);
        }
        else
        {
// 4
            sharedInstance = this;
        }
    }

    void Start()
    {
// 5
        SetupScene();
    }

// 6
    public void SetupScene()
    {
        // empty, for now
    }
}

// 1

一个static变量:sharedInstance用于访问 Singleton 对象。通过这个属性应该只能访问单例。

重要的是要理解static变量属于类本身 (RPGGameManager),而不是该类的一个特定实例。属于类本身的一个结果是内存中只存在一个RPGGameManager.sharedInstance的副本。

如果我们在 Hierarchy 视图中创建两个 RPGGameManager 对象,第二个要初始化的对象将与第一个 RPGGameManager 共享同一个 sharedInstance。这种情况本来就令人困惑,所以我们将采取措施防止它发生。

检索对sharedInstance的引用的语法:

RPGGameManager gameManager = RPGGameManager.sharedInstance;
// 2

我们只希望一次存在一个RPGGameManager实例。检查sharedInstance是否已经初始化并且不等于当前实例。如果您以某种方式在层次中创建 RPGGameManager 的多个副本,或者如果您以编程方式实例化 RPGGameManager 预置的副本,这种情况是可能发生的。

// 3

如果sharedInstance已经初始化,不等于当前实例,那么销毁它。应该只有一个 RPGGameManager 实例。

// 4

如果这是唯一的实例,则将sharedInstance变量赋给当前对象。

// 5

将所有逻辑整合到一个方法中来设置场景。这使得将来从Start()方法之外的地方再次调用变得更加容易。

// 6

SetupScene()方法暂时是空的,但是很快就会改变。

构建一个游戏管理器预置

让我们创建一个 RPGGameManager 预置。遵循同样的过程,我们总是用游戏对象来创建预置:

  1. 将 RPGGameManager 游戏对象从层次视图拖动到项目视图的预设文件夹中,创建一个预设。

  2. 通常我们会从层次视图中删除原始的 RPGGameManager 对象。这一次,将它保留在层次视图中,因为我们还没有完成对它的处理。

我们创建了一个负责运行游戏的集中管理类。因为它是单例的,所以一次只存在 RPGGameManager 类的一个实例。

产生点数

我们希望能够在场景中的特定位置创建或“繁殖”角色——一个玩家或一个敌人。如果我们在繁殖敌人,那么我们可能也想定期繁殖它们。为了完成这一点,我们将创建一个产卵点预置,并附上一个脚本与产卵逻辑。

在层级视图中右键单击,创建一个空的游戏对象,并将其重命名为:“SpawnPoint”。

向我们刚刚创建的名为“SpawnPoint”的 SpawnPoint 对象添加一个新的 C# 脚本。将脚本移动到 MonoBehaviours 文件夹。

在 Visual Studio 中打开 SpawnPoint 脚本,并使用以下代码:

using UnityEngine;

public class SpawnPoint : MonoBehaviour
{

// 1
    public GameObject prefabToSpawn;

// 2
    public float repeatInterval;

    public void Start()
    {
// 3
        if (repeatInterval > 0)
        {
// 4
            InvokeRepeating("SpawnObject", 0.0f, repeatInterval);
        }
    }

// 5
    public GameObject SpawnObject()
    {

// 6
        if (prefabToSpawn != null)
        {
// 7
            return Instantiate(prefabToSpawn, transform.position, Quaternion.identity);
        }

// 8
        return null;
    }
}

// 1

这可能是任何我们想要一次或在一个一致的时间间隔产卵的预置。我们将在 Unity 编辑器中将它设置为玩家或敌人的预设。

// 2

如果我们想定期生成预设,我们将在 Unity 编辑器中设置这个属性。

// 3

如果repeatInterval大于 0,那么我们表示对象应该在某个预设的时间间隔重复产生。

// 4

因为repeatInterval大于 0,我们使用InvokeRepeating()以规则的、重复的间隔产生对象。InvokeRepeating()的方法签名有三个参数:要调用的方法、第一次调用前等待的时间以及两次调用之间等待的时间间隔。

// 5

SpawnObject()负责实例化预置和“生成”对象。方法签名表明它将返回类型为:GameObject的结果,这将是衍生对象的一个实例。我们将这个方法的访问修饰符设置为:public,这样就可以从外部调用了。

// 6

为了避免错误,在实例化副本之前,检查以确保我们已经在 Unity 编辑器中设置了预置。

// 7

在当前 SpawnPoint 对象的位置实例化预设。有几种不同类型的Instantiate方法用于实例化预设。我们使用的具体方法是一个预置,一个指示位置的Vector3,和一个称为四元数的特殊类型的数据结构。四元数用来表示旋转,Quaternion.identity表示“不旋转”所以我们在没有旋转的情况下,在种子点的位置实例化预设。我们不会讨论四元数,因为它们可能非常复杂,超出了本书的范围。

返回预置的新实例的引用。

// 8

如果prefabToSpawn为空,那么这个种子点可能没有在编辑器中正确配置。返回null

建立一个产卵点预置

计划是这样的:我们将首先为玩家设置一个产卵点,看看所有的碎片是如何组合在一起的,然后我们将为敌人设置一个产卵点。要构建一个通用的 SpawnPoint,将我们刚刚编写的脚本添加到 SpawnPoint 游戏对象中,然后创建一个预置。

按照下面的过程来创建一个预置的游戏对象:

  1. 从层级视图中拖动游戏对象到项目视图中的预设文件夹,创建一个预设。

  2. 从层次视图中删除原始 SpawnPoint 对象。

将 SpawnPoint 预设拖到你希望玩家出现的场景中。将 Spawn Point 的新实例重命名为“PlayerSpawnPoint”,如图 7-1 所示。不要按“应用”按钮,因为我们不想把这个改变应用到预置本身——只应用到这个实例。

img/464283_1_En_7_Fig1_HTML.jpg

图 7-1

重命名产卵点

正如你在图 7-2 中看到的,在场景中几乎看不到产卵点的位置。因为 GameObject 实例没有附加精灵,所以很难看到。

img/464283_1_En_7_Fig2_HTML.jpg

图 7-2

没有精灵的游戏对象有时很难在场景视图中看到

小费

当游戏没有运行时,为了使产卵点更容易在场景中定位,选择产卵点,然后按下检查器左上角的图标,如图 7-3 所示。

img/464283_1_En_7_Fig3_HTML.jpg

图 7-3

在检查器中选择图标

选择一个图标来直观地表示场景中的选定对象。你应该看到选中的图标出现在场景中的物体上,如图 7-4 所示。

img/464283_1_En_7_Fig4_HTML.jpg

图 7-4

使用图标使对象更容易在场景中找到

这些图标也可以在运行时通过选择游戏窗口右上角的 Gizmos 按钮来显示,如图 7-5 所示。

img/464283_1_En_7_Fig5_HTML.jpg

图 7-5

使用 Gizmos 按钮设置图标在运行时可见

配置玩家产卵点

我们仍然需要配置产卵点,以便它知道要产卵的预设。如图 7-6 所示,通过将 PlayerObject 预设拖到相应的属性,将附加的 Spawn Point 脚本中的“预设生成”属性设置为 PlayerObject 预设。让重复间隔设置为 0,因为我们只想繁殖玩家一次。

img/464283_1_En_7_Fig6_HTML.jpg

图 7-6

配置种子点脚本

因为计划是使用 PlayerSpawnPoint 来生成播放器,所以从 Hierarchy 视图中删除播放器实例。

按下播放,你会立即注意到没有任何变化。那个运动员不见了。这是因为我们实际上还没有在任何地方调用 SpawnPoint 类的SpawnObject()方法。让我们修改 RPGGameManager 来调用SpawnObject()

切换回 Unity 编辑器并打开 RPGGameManager 类。

产生玩家

将以下属性添加到类的顶部:

public class RPGGameManager : MonoBehaviour
{

// 1
    public SpawnPoint playerSpawnPoint;

        // ...Existing code from the RPGGameManager class...
}

// 1

属性将保存一个特别为玩家指定的种子点的引用。我们保留了这个特定的重生点的参考,因为我们希望当玩家过早死亡时能够重生

添加以下方法:

public void SpawnPlayer()
{

// 1
    if (playerSpawnPoint != null)
    {
// 2
        GameObject player = playerSpawnPoint.SpawnObject();
    }
}

// 1

在我们尝试使用它之前,检查一下playerSpawnPoint属性是否不为空。

// 2

调用playerSpawnPoint.SpawnObject上的SpawnObject()方法来生成播放器。存储对实例化播放器的本地引用,我们很快就会用到它。

在 RPGGameManager 的SetupScene()方法中,添加一行:

public void SetupScene()
{

// 1
    SpawnPlayer();
}

// 1

这将调用我们刚刚编写的SpawnPlayer()方法。

最后,我们需要在 Hierarchy 视图中配置 RPGGameManager 实例,引用玩家种子点。将 PlayerSpawnPoint 从 Hierarchy 视图中拖放到 RPGGameManager 实例中的 Player Spawn Point 属性中,如图 7-7 所示。

img/464283_1_En_7_Fig7_HTML.jpg

图 7-7

将 Player Spawn Point 属性设置为 PlayerSpawnPoint 实例

按 Play,你应该会看到玩家对象出现在场景中玩家产卵点的位置。

概括起来

  1. 产卵点用于确定产卵的对象类型和产卵的位置。我们已经配置了玩家种子点实例来引用玩家对象预置。

  2. 在 RPGGameManager 实例中配置对玩家种子点的引用。

  3. 在 RPGGameManager 的SetupScene()方法中,调用 Player Spawn Point 类的SpawnObject()方法。

敌人的滋生地

让我们建造一个出生点来繁殖敌人。因为我们已经建立了一个产卵点预置,这将是快速的。

  1. 拖放一个预置到场景中。

  2. 将其重命名为 EnemySpawnPoint。

    • (可选)将图标更改为红色,以便我们可以在场景视图中轻松查看它
  3. 将“预设为产卵”属性设置为敌人预设。

  4. 将重复间隔设置为 10 秒,每 10 秒产生一个敌人。

配置完敌人的产卵点后,场景应该类似于图 7-8 。

img/464283_1_En_7_Fig8_HTML.jpg

图 7-8

SpawnPoint 的一个实例,配置为使用自定义的红色图标来繁殖敌人,使其容易被看到

按下播放键,每 10 秒钟就会有敌人出现。我们还没有编写任何人工智能来使敌人移动或攻击,所以玩家暂时是安全的。

当你带着玩家在地图上走来走去的时候,你可能已经注意到有什么不对劲了。镜头不再跟随玩家!大灾难!这是因为我们现在正在动态生成玩家,而不是在 Cinemachine 虚拟相机中设置玩家预置实例——follow 属性。虚拟摄像机没有跟随目标,因此保持在同一位置。

摄像机管理器

为了恢复相机跟随玩家在地图上走动的行为,我们将创建一个相机管理器类,并让游戏管理器使用它来确保虚拟相机被正确设置。这个摄像头管理器在未来会很有用,它是一个集中配置摄像头行为的地方,而不是将摄像头代码嵌入到我们应用的各个地方。

在层次中创建一个新的游戏对象,并将其重命名为:RPGCameraManager。创建一个名为 RPGCameraManager 的新脚本,并将其添加到 RPGCameraManager 对象中。在 Visual Studio 中打开该脚本。

我们将再次使用单例模式,就像我们在本章前面对 RPGGameManager 所做的那样。

对 RPGCameraManager 类使用以下代码:

using UnityEngine;

// 1
using Cinemachine;

public class RPGCameraManager : MonoBehaviour {

    public static RPGCameraManager sharedInstance = null;

// 2
      [HideInInspector]
    public CinemachineVirtualCamera virtualCamera;

// 3
    void Awake()
    {
        if (sharedInstance != null && sharedInstance != this)
        {
            Destroy(gameObject);
        }
        else
        {
            sharedInstance = this;
        }

// 4
        GameObject vCamGameObject = GameObject.FindWithTag("VirtualCamera");

//5
        virtualCamera = vCamGameObject.GetComponent<CinemachineVirtualCamera>();
    }
}

// 1

导入Cinemachine名称空间,以便 RPGCameraManager 能够访问 Cinemachine 类和数据类型。

// 2

存储对 Cinemachine 虚拟摄像机的引用。使其成为public以便其他类可以访问它。因为我们将以编程方式设置它,所以使用[HideInInspector]属性,这样它就不会出现在 Unity 编辑器中。

// 3

实现单例模式。

// 4

在当前场景中找到虚拟摄像机游戏对象。在下面一行中,我们将获得对其虚拟相机组件的引用。我们还需要在 Unity 编辑器中创建这个标签,并配置虚拟摄像机来使用它。

记住游戏对象可以有多个组件,每个组件提供不同的功能。这就是所谓的“组合”设计模式。

// 5

虚拟摄像机的所有属性,如跟随目标和正交尺寸,都可以通过脚本和 Unity 编辑器进行配置。保存对虚拟摄像机组件的引用,这样我们就可以通过编程来控制这些虚拟摄像机属性。

从 RPGCameraManager 创建一个预置,但在层次视图中保留一个实例。

使用相机管理器

在 RPGGameManager 类中,将以下属性添加到该类的顶部:

public RPGCameraManager cameraManager;

我们制作这个属性public是因为我们要通过 Unity 编辑器来设置它。RPGGameManager 将在生成播放器时使用对 RPGCameraManager 的引用,正如您将在下面的代码中看到的那样。

仍然在 RPGGameManager 类中,将SpawnPlayer()方法更改为以下内容:

public void SpawnPlayer()
{
    if (playerSpawnPoint != null)
    {
        GameObject player = playerSpawnPoint.SpawnObject();
// 1
        cameraManager.virtualCamera.Follow = player.transform;
    }
}

// 1

我们已经将这一行添加到了SpawnPlayer()。将virtualCameraFollow属性设置为player对象的transform。这将指示 Cinemachine 虚拟摄像机在玩家在地图上走动时再次跟随她。

切换回 Unity 编辑器,并在层次结构中选择 RPGGameManager 实例。我们将配置游戏管理器来使用相机管理器。

将 RPGCameraManager 实例拖动到层次结构中 RPGGameManager 的 CameraManager 属性中,如图 7-9 所示。

img/464283_1_En_7_Fig9_HTML.jpg

图 7-9

设置摄像机管理器属性

在我们的虚拟摄像机再次跟随玩家之前,还有最后一件事要做:在虚拟摄像机上设置标签,以便 RPGCameraManager 脚本可以找到它。

在层次视图中选择虚拟摄影机对象。默认情况下,虚拟摄像机将被命名为:CM vcam1。点按检查器中的“标签”下拉菜单。如果您需要复习标签下拉菜单的位置,请看图 7-10 。

img/464283_1_En_7_Fig10_HTML.jpg

图 7-10

标签下拉菜单

将名为“VirtualCamera”的标签添加到标签列表中。然后在层次中再次选择虚拟相机对象,并将标签设置为您刚刚创建的 Virtual Camera 标签(图 7-11 )。

img/464283_1_En_7_Fig11_HTML.jpg

图 7-11

将标记设置为 VirtualCamera,以便 RPGCameraManager 脚本可以找到它

再次按下播放键,让玩家在地图上走一圈。当玩家在地图上走来走去时,摄像机应该再次跟随她。

角色类设计

如果你还记得在第六章中,我们设计了一个名为:Character 的类。目前,只有玩家职业从角色继承,但在未来,每个从角色继承的职业都需要对其他角色造成伤害,对其造成伤害,甚至死亡的能力。这一章的剩余部分将涉及到设计和扩充角色、玩家和敌人的职业。

虚拟关键字

C# 中的“virtual”关键字用于声明类、方法或变量将在当前类中实现,但是如果当前实现不充分,也可以在继承类中覆盖**。**

**在下面的代码中,我们构建了杀死一个角色的基本功能,但是继承类可能需要额外的功能。

因为我们游戏中的所有角色都是凡人,我们将在父类中提供一个杀死他们的方法。将以下内容添加到角色类的底部:

// 1
public virtual void KillCharacter()
{
// 2
    Destroy(gameObject);
}

// 1

当人物生命值为零时,将调用此方法。

// 2

当角色被杀死时,调用Destroy(gameObject)将破坏当前游戏对象并将其从场景中移除。

敌人阶级

成为英雄的一部分是面对逆境和可能的危险。在这一部分,我们将建造一个敌人职业,并赋予它伤害玩家的能力。

在第六章中,我们用一个巧妙的技巧用可脚本化的对象构建了一个名为HitPoints的可脚本化的对象,它可以立即与玩家的生命值栏共享数据。Character 类包含一个由继承自 Character 的 Player 类使用的类型为HitPoints的属性。

因为我们游戏中的敌人不会有屏幕上的生命值条,所以他们不需要一个HitPoints ScriptableObject。只有拥有健康栏的玩家需要访问一个HitPoints ScriptableObject。因此,我们可以通过简单地使用一个常规的float variable to track hit-points instead.来简化我们追踪敌方职业生命值的方法

重构

为了简化我们的类架构,我们将重构一些代码。重构代码是一个简单的术语,用来在不改变现有代码行为的情况下对其进行重构。

在 Visual Studio 中打开角色类和播放器类。将hitPoints变量从角色类移至玩家类,移至我们已有属性的顶部:

public HitPoints hitPoints;

选择敌人对象预设,并添加一个名为:敌人的脚本。在 Visual Studio 中打开敌方脚本。删除敌人类中的默认代码,并替换为以下代码。

using UnityEngine;

// 1
public class Enemy : Character
{

// 2
    float hitPoints;
}

// 1

我们的敌人类继承自 Character,这意味着它可以访问 Character 类中的公共属性和方法。

// 2

类型为float的简化的hitPoints变量。

在这些代码更改之后,我们的玩家职业将继续使用我们在第六章中创建的 HitPoints ScriptableObject。我们还创建了一个敌人类,它包含了一个追踪生命值的简单方法。敌人职业也获得了角色职业中与生命值相关的现有属性:startingHitPointsmaxHitPoints

小费

当重构代码时,最好保持较小的变化,然后进行测试以确保正确的行为,从而最小化引入新错误的机会。进行小的改变,然后测试的迭代循环是保持你理智的好方法。

内部访问修饰符

注意,我们在敌人类的hitPoints变量前面省略了任何访问修饰符关键字(publicprivate)。在 C# 中,缺少访问修饰符意味着默认情况下将使用internal访问修饰符。internal访问修饰符将对变量或方法的访问限制在同一个“程序集”内汇编是 C# 中使用的一个术语,可以认为包含了 C# 项目。

协同程序

我们将暂停一下构建角色和敌人的职业,来讨论一下合一的一个重要而有用的特性。当在 Unity 中调用一个方法时,该方法一直运行到完成,然后返回到最初的调用点。常规方法中发生的一切都必须发生在 Unity 引擎的单个框架中。如果你的游戏调用了一个运行时间超过一帧的方法,Unity 实际上会强制整个方法在该帧内被调用。当这种情况发生时,你不会得到你想要的结果。甚至有可能用户看不到应该运行几秒钟的方法的结果,因为它将在单个框架内运行和完成。

为了解决这个难题,Unity 提供了一个叫做协程的东西。协程可以被认为是可以在执行过程中暂停,然后在下一帧继续执行的函数。打算在多个帧的过程中执行的长时间运行的方法通常被实现为协程。

声明协程和使用返回类型一样简单:IEnumerator并在方法体中的某个地方包含一行指令 Unity 引擎暂停或“让步”。正是这条yield线告诉引擎暂停执行并返回到后续帧中的相同点。

调用协程

一个名为RunEveryFrame()的假想协程可以通过将其包含在方法StartCoroutine()中来启动,如下所示:

StartCoroutine(RunEveryFrame());

暂停或“放弃”执行

RunEveryFrame()将一直运行,直到到达一个yield语句,此时它将暂停,直到下一帧,然后继续执行。一个yield声明可能看起来像:

yield return null;

完整的协程

下面的RunEveryFrame()方法只是协程的一个例子。不要把它添加到你的代码中,但是要确保你理解它是如何工作的:

public IEnumerator RunEveryFrame()
{

// 1
    while(true)
    {
        print("I will print every Frame.");
        yield return null;
    }
}

// 1

我们将printyield语句包含在一个while()循环中,以保持该方法无限期运行,也就是说,使其长期运行并跨越多个帧。

具有时间间隔的协同程序

协程也可以用于以固定的时间间隔调用代码,比如每 3 秒,而不是每一帧。在下一个例子中,我们没有使用yield return null来暂停,而是使用yield return new WaitForSeconds()并传递一个时间间隔参数:

public IEnumerator RunEveryThreeSeconds()
{
    while (true)
    {
        print("I will print every three seconds.");
        yield return new WaitForSeconds(3.0f);
    }
}

当这个示例协程到达yield语句时,执行将暂停 3 秒钟,然后恢复。由于while()循环,每三秒钟就会调用并打印一次print语句。

我们将编写一些协程来构建角色、玩家和敌人类的功能。

抽象关键字

C# 中的“abstract”关键字用于声明类、方法或变量不能在当前类中实现,而必须由继承类实现

敌人和玩家职业都继承自角色职业。通过将以下方法的定义放在角色类中,我们要求敌人和玩家类在游戏编译和运行之前实现它们。

将下面的"using"语句添加到角色类的顶部。我们需要导入System.Collections来使用协程。

using System.Collections;

然后在KillCharacter()方法下添加以下内容:

// 1
public abstract void ResetCharacter();

// 2
public abstract IEnumerator DamageCharacter(int damage, float interval);

// 1

将角色设置回其原始开始状态,以便可以再次使用。

// 2

被其他角色调用来伤害当前角色。对角色造成的伤害量和时间间隔。该时间间隔可用于反复出现损坏的情况。

如前所述,返回类型:IEnumerator在协程中是必需的。IEnumeratorSystem.Collections名称空间的一部分,这就是为什么我们必须在前面添加导入行:using System.Collections

请记住,所有抽象方法都必须在代码编译和运行之前实现。因为这个方法在玩家和敌人的父类中,所以我们必须在两个类中都实现这两个方法。

实现敌人类

既然我们是协程专家,并且已经构建了角色类,我们将从DamageCharacter()协程开始实现抽象方法。

想象一下我们游戏中的一个场景,一个敌人撞上了玩家,而玩家没有让开。我们的游戏逻辑说,只要敌人和玩家保持联系,敌人就会持续伤害她。另一个定期造成伤害的场景是玩家走过熔岩。那只是科学。

为了实现这个场景,我们已经将DamageCharacter()方法声明为一个协程,以允许该方法定期应用损害。在DamageCharacter()的实现中,我们将利用:yield return new WaitForSeconds()将执行暂停一段指定的时间。

DamageCharacter()方法

将以下导入添加到类的顶部:

using System.Collections;

我们需要导入System.Collections来使用协程。

在敌人类内部实现DamageCharacter()方法:

// 1
public override IEnumerator DamageCharacter(int damage, float interval)
{

// 2
    while (true)
    {

// 3
        hitPoints = hitPoints - damage;

// 4
        if (hitPoints <= float.Epsilon)
        {
// 5
            KillCharacter();
            break;
        }

// 6
        if (interval > float.Epsilon)
        {
            yield return new WaitForSeconds(interval);
        }
        else
        {
// 7
            break;
        }
    }
}

// 1

当在一个派生(继承)类中实现一个abstract方法时,使用override关键字来指示该方法正在从基类(父类)中覆盖KillCharacter()方法。

这个方法需要两个参数:damageintervalDamage是对角色造成的伤害量,interval是施加damage之间等待的时间。传递一个interval = 0,正如我们将看到的,将造成damage一次,然后返回。

// 2

这个while()循环将继续施加damage直到角色死亡,或者如果interval = 0,它将break并返回。

// 3

从电流hitPoints中减去damage的量,并将结果设置为hitPoints

// 4

调整敌人的hitPoints后,我们想检查一下hitPoints是否小于 0。然而,hitPoints的类型是:float,由于floats的实现方式,浮点运算容易出现舍入误差。因此,在某些情况下,最好将float值与float.Epsilon值进行比较,后者定义为当前系统中“大于零的最小正值”。为了敌人的生死,如果hitPoints小于float.Epsilon,那么这个角色的生命值为零。

// 5

如果hitPoints小于float.Epsilon(实际上为 0),那么敌人已经被击败。呼叫KillCharacter()然后脱离while()循环。

// 6

如果interval大于float.Epsilon,那么我们要执行yield,等待interval秒,然后继续执行while()循环。在这种情况下,循环只有在角色死亡时才会退出。

// 7

如果interval不大于float.Epsilon(实际上等于 0),那么这个break语句将被命中,while()循环将被中断,方法将返回。参数interval在伤害不连续的情况下将为零,比如单次命中。

让我们实现 Character 类中声明的其余抽象方法。

在敌人阶层:

ResetCharacter()

Lets build out the method to set the Character variables back to their original state. It's important to do this if we want to use the Character object again after it dies. This method can also be used to set up the variables when the Character is first created.
// 1
public override void ResetCharacter()
{
// 2
    hitPoints = startingHitPoints;
}

// 1

因为敌人类继承自角色类,所以我们在父类中override声明ResetCharacter()

// 2

重置角色时,将当前生命值设置为startingHitPoints。我们在 Unity 编辑器中将startingHitPoints设置在预置本身上。

在 OnEnable()中调用 ResetCharacter()

敌方职业继承自角色,角色继承自MonoBehaviourOnEnable()方法是MonoBehaviour类的一部分。如果OnEnable()是在一个类中实现的,它将在每次一个对象被激活时被调用。我们将使用OnEnable()来确保每次敌方目标被激活时都会发生一些事情。

private void OnEnable()
{

// 1
    ResetCharacter();
}

// 1

调用我们刚刚写的方法重置敌人。目前,“重置”敌人仅仅意味着将hitPoints设置为startingHitPoints,但是我们也可以在ResetCharacter()中包含其他东西。

KillCharacter()

因为我们已经在角色类中将KillCharacter()实现为一个virtual方法,而敌人继承自角色,所以不需要在敌人类中实现它。除了角色实现提供的功能之外,敌人不需要任何额外的功能。

更新播放器类

接下来,我们将在 Player 类中实现抽象方法。在 Visual Studio 中打开 Player 类,并使用下面的代码实现 Character 父类的抽象方法。

将以下导入添加到类的顶部:

using System.Collections;

然后将下面的方法添加到 Player 类中:

// 1
public override IEnumerator DamageCharacter(int damage, float interval)
{
    while (true)
    {
        hitPoints.value = hitPoints.value - damage;

        if (hitPoints.value <= float.Epsilon)
        {
            KillCharacter();
            break;
        }

        if (interval > float.Epsilon)
        {
            yield return new WaitForSeconds(interval);
        }
        else
        {
            break;
        }
    }
}

// 1

实现 DamageCharacter()方法,就像我们在敌人类中做的那样。

public override void KillCharacter()
{
// 1
    base.KillCharacter();

// 2
    Destroy(healthBar.gameObject);
    Destroy(inventory.gameObject);
}

// 1

使用base关键字来引用当前类继承的父类或“基类”。调用base.KillCharacter()会调用父类中的KillCharacter()方法。父KillCharacter()方法销毁当前与玩家关联的gameObject

// 2

摧毁玩家的生命值和物品。

重构预设实例化

在第六章中,我们在Start()方法中初始化了生命条和库存预置的实例。这是在我们有方法之前:ResetCharacter()。从Start()上取下以下三条线,放入ResetCharacter()内,如下图所示:

Start()中删除这三行:

inventory = Instantiate(inventoryPrefab);
healthBar = Instantiate(healthBarPrefab);
healthBar.character = this;

然后创建方法ResetCharacter(),如下所示,在角色父类中覆盖方法abstract:

public override void ResetCharacter()
{

// 1
    inventory = Instantiate(inventoryPrefab);
    healthBar = Instantiate(healthBarPrefab);
    healthBar.character = this;

// 2
    hitPoints.value = startingHitPoints;
}

// 1

我们从 Start()方法中删除的三行代码。这三行代码初始化并设置健康栏和库存。

// 2

将玩家的生命值设定为起始生命值。记住——因为起始生命值是公开的,我们可以在 Unity 编辑器中设置它。

回顾

让我们回顾一下我们刚刚构建的内容:

  • 角色类为我们游戏中所有不同的角色类型提供了基本的功能,包括玩家和他的敌人。

  • 角色类功能包括:

    • 杀死一个角色的基本功能

    • 重置角色的抽象方法定义

    • 损坏角色的抽象方法定义

利用我们已经建立的

我们已经构建了一些非常好的核心功能,但是我们实际上还没有使用它。敌人有可以伤害玩家的方法,但是他们现在没有被调用。为了查看DamageCharacter()KillCharacter()方法的运行情况,我们将向敌人类添加功能,当玩家遇到敌人类时,敌人类将调用 DamageCharacter()方法。

在敌人类中,将这两个变量添加到类的顶部:

// 1
public int damageStrength;

// 2
Coroutine damageCoroutine;

// 1

在 Unity 编辑器中设置,这个变量将决定敌人碰到玩家时会造成多大的伤害。

// 2

对正在运行的协程的引用可以保存到一个变量中,并在以后停止。我们将使用damageCoroutine来存储对DamageCharacter()协程的引用,这样我们可以在以后停止它。

二维 oncollisionenter

OnCollisionEnter2D()是一个包含在所有 MonoBehaviours 中的方法,每当当前对象Collider2D与另一个Collider2D接触时,Unity 引擎就会调用它。

// 1
void OnCollisionEnter2D(Collision2D collision)
{

// 2
    if(collision.gameObject.CompareTag("Player"))
    {

// 3
        Player player = collision.gameObject.GetComponent<Player>();

// 4
        if (damageCoroutine == null)
        {
            damageCoroutine = StartCoroutine(player.DamageCharacter(damageStrength, 1.0f));
        }
    }
}

// 1

碰撞细节作为参数:collision,传入OnCollisionEnter2D()

// 2

我们想写游戏逻辑,让敌人只能伤害玩家。对比敌人碰撞过的物体上的标签,看看是不是玩家物体。

// 3

此时,我们已经确定另一个对象是播放器,因此检索对播放器组件的引用。

// 4

查看这个敌人是否已经在运行DamageCharacter()协程。如果不是,那么在播放器对象上启动协程。传入DamageCharacter()``damageStrengthinterval,因为只要它们接触,敌人就会持续伤害玩家。

我们正在做一件前所未见的事情。我们在变量damageCoroutine中存储了对正在运行的协程的引用。我们可以调用StopCoroutine()并给它传递参数:damageCoroutine,以便随时停止协程。

oncollonixis 2d

当另一个对象的Collider2D停止接触当前 MonoBehaviour 对象的Collider2D时,调用OnCollisionExit2D()

// 1
void OnCollisionExit2D(Collision2D collision)
{

// 2
    if (collision.gameObject.CompareTag("Player"))
    {

// 3
        if (damageCoroutine != null)
        {
// 4
            StopCoroutine(damageCoroutine);
            damageCoroutine = null;
        }
    }
}

// 1

碰撞细节作为参数:collision,传入OnCollisionEnter2D()

// 2

检查敌人停止碰撞的物体上的标签,看看它是否是玩家物体。

// 3

如果damageCoroutine不为空,这意味着协程正在运行,应该停止,然后设置为null

// 4

停止实际上是DamageCharacter()damageCoroutine,并将其设置为null。这将立即停止协程。

配置敌人脚本

翻回到 Unity 编辑器,配置敌人脚本,如图 7-12 所示。记住伤害强度就是敌人碰到她会对玩家造成多大的伤害。

img/464283_1_En_7_Fig12_HTML.jpg

图 7-12

配置敌人脚本

按下播放,并步行到一个敌人产卵点的球员。让玩家撞上一个敌人,你会注意到玩家受到一些伤害,但也会把敌人推开。这是因为玩家和敌人都有 RigidBody2D 组件附着在他们身上,并且受 Unity 的物理引擎控制。

最终敌人会追着玩家跑,但是现在,把敌人逼到墙角,保持和它的联系。观察生命值下降到 0,直到物品、生命值和玩家从屏幕上消失。

摘要

我们的样本游戏真的开始走到一起了。我们已经为整个游戏中的各种类型的角色创建了一个架构,并在这个过程中获得了一些关于使用 C# 的指导。我们的游戏现在有一个中央游戏管理器,负责设置场景,生成玩家,并确保相机设置正确。我们已经学习了如何编写代码来编程控制摄像机,而以前我们必须通过 Unity 编辑器来设置摄像机。我们构建了一个 Spawn Point 来生成不同的角色类型,并学习了协程,这是 Unity 开发人员工具箱中的一个重要工具。**

八、人工智能和弹弓

这一章涵盖了很多,但是到最后,你会有一个游戏的功能原型。我们将构建一些有趣的功能,如具有追逐行为的可重用人工智能组件。我们勇敢的玩家也将最终得到她选择的武器:一把弹弓,用来保护自己。您将学习一种在游戏编程中广泛使用的优化技术,称为对象池,并运用一些您从未想过会用到的高中数学知识。本章还演示了混合树的使用,这是一种更有效的制作动画的方式,从长远来看,对你的游戏架构更好。最后,我们将向您展示如何在 Unity 之外编译您的游戏,并谈一谈您的游戏编程冒险的下一步。

游走算法

在这一节中,我们将利用我们所学的协程编写一个脚本,让敌人在棋盘上随机游走。如果敌人察觉到玩家就在附近,敌人就会追击她,直到她逃跑,杀死敌人,或者玩家死亡。

Wander 算法听起来可能很复杂,但是当我们一步一步地分解它时,你会发现它是完全可以实现的。

图 8-1 是游走算法的示意图。我们将分阶段实现每个部分,并在过程中进行解释,这样您就不会感到不知所措。

img/464283_1_En_8_Fig1_HTML.png

图 8-1

游走算法

入门指南

选择敌人的预设,并将其拖入场景中,使我们的生活更容易。选择 EnemyObject 并向其添加 CircleCollider2D 组件。选中圆形碰撞器上的 Is 触发框,将碰撞器的半径设置为:1。圆形碰撞器应该看起来像图 8-2 。

img/464283_1_En_8_Fig2_HTML.jpg

图 8-2

设置是触发器和半径

这个圆形对撞机代表了敌人能“看”多远。换句话说,当玩家的对撞机穿越圆形对撞机时,敌人可以看到玩家。记住触发碰撞器是如何工作的:因为我们已经检查了圆形碰撞器上的是触发框,它可以穿过其他物体。敌人会“看到”玩家穿越对撞机,然后改变航向,追击她。

创建漫游脚本

我们将创建一个单行为的漫游脚本,这样它就可以被重复使用,并在将来附加到敌人以外的其他游戏对象上。

添加一个新的脚本,名为:“Wander”。在 Visual Studio 中打开该脚本,并添加以下内容:

// 1
using System.Collections;
using UnityEngine;

// 2
[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(CircleCollider2D))]
[RequireComponent(typeof(Animator))]
public class Wander : MonoBehaviour
{

}

// 1

我们将在 Wander 算法中使用协程和IEnumerator。正如在第七章中提到的,IEnumeratorSystem.Collections名称空间的一部分,所以我们在这里导入它。

// 2

确保我们将来附加漫游脚本的任何游戏对象都有一个Rigidbody2D、一个CircleCollider2D和一个Animator。这三个组件都是 Wander 脚本所必需的。

通过使用RequireComponent,这个脚本附加到的任何脚本将自动添加所需的组件(如果它还不存在的话)。

漂移变量

接下来我们将勾画出游走算法所需的变量。将以下变量添加到 Wander 类中:

// 1
    public float pursuitSpeed;
    public float wanderSpeed;
    float currentSpeed;

// 2
    public float directionChangeInterval;

// 3
    public bool followPlayer;

// 4
    Coroutine moveCoroutine;

// 5
    Rigidbody2D rb2d;
    Animator animator;

// 6
    Transform targetTransform = null;

// 7
    Vector3 endPosition;

// 8
    float currentAngle = 0;

// 1

这三个变量将用于设置敌人追击玩家的速度,不追击时的一般游荡速度,以及将是前两个速度之一的当前速度。

// 2

The directionChangeInterval通过 Unity 编辑器设置,将用于确定敌人应该多久改变一次游荡方向。

// 3

这个脚本可以附加到游戏中的任何角色上,添加流浪行为。你可能希望最终创造一个不追逐玩家而只是四处游荡的角色。可以设置followPlayer标志来开启和关闭玩家追逐行为。

// 4

变量 moveCoroutine 是我们保存对当前运行的移动协程的引用的地方。这个协程将负责在每一帧中向目的地移动敌人一点点。我们需要保存对协程的引用,因为在某个时候我们需要停止它,为此我们需要一个引用。

// 5

附加在游戏对象上的刚体 2D 和动画。

// 6

We use targetTransform敌人追击玩家时。该脚本将从 PlayerObject 中检索转换,并将其分配给targetTransform

// 7

敌人游荡的目的地。

// 8

当选择一个新的方向漫游时,一个新的角度将添加到现有的角度。该角度用于生成一个矢量,该矢量成为目的地。

构建开始()

现在我们已经有了目前需要的所有变量,让我们构建 Start()方法。

    void Start()
    {
// 1
        animator = GetComponent<Animator>();

// 2
        currentSpeed = wanderSpeed;

// 3
        rb2d = GetComponent<Rigidbody2D>();

// 4
        StartCoroutine(WanderRoutine());
    }

// 1

抓取并缓存当前游戏对象的动画组件。

// 2

将当前速度设置为wanderSpeed。敌人开始悠闲地游荡。

// 3

我们需要参考Rigidbody2D来实际移动敌人。存储一个引用,而不是每次需要时都检索它。

// 4

启动WanderRoutine()协程,这是 Wander 算法的入口点。接下来我们写WanderRoutine()

流浪的协程

除了追踪逻辑外,WanderRoutine()协程包含本章前面图 8-1 中描述的 Wander 算法的所有高级逻辑。我们仍然需要编写一些从WanderRoutine()内部调用的方法,但是这个协程是 Wander 算法的大脑。

// 1
public IEnumerator WanderRoutine()
{

// 2
    while (true)
    {

// 3
        ChooseNewEndpoint();

//4
        if (moveCoroutine != null)
        {

// 5
            StopCoroutine(moveCoroutine);
        }

// 6
        moveCoroutine = StartCoroutine(Move(rb2d, currentSpeed));

// 7
        yield return new WaitForSeconds(directionChangeInterval);
    }
}

// 1

这个方法是一个协程,因为它无疑会在多个框架上运行。

// 2

我们希望敌人无限期地游荡,所以我们将使用 while(true)来无限期地循环这些步骤。

// 3

ChooseNewEndpoint()方法确实如其名。它会选择一个新的终点,但不会让敌人朝它移动。接下来我们将编写这个方法。

// 4

通过检查moveCoroutine是否为null或是否有值来检查敌人是否已经在移动。如果它有一个值,那么敌人可能正在移动,所以我们需要在移动到新的方向之前先阻止它。

// 5

停止当前运行的运动协程。

// 6

启动Move()协程,并在moveCoroutine中保存对它的引用。Move()协程负责实际移动敌人。我们很快就会写出来。

// 7

让协程执行directionChangeInterval秒,然后重新开始循环并选择一个新的端点。

选择新端点

我们已经写出了起点和 Wander 协程,所以是时候开始填充由WanderCoroutine()调用的方法了。ChooseNewEndpoint()方法负责随机选择一个新的终点供敌人行进。

// 1
void ChooseNewEndpoint()
{

// 2
    currentAngle += Random.Range(0, 360);

// 3
    currentAngle = Mathf.Repeat(currentAngle, 360);

// 4
    endPosition += Vector3FromAngle(currentAngle);
}

// 1

通过省略访问修饰符使这个方法私有,因为它只在 Wander 类中需要。

// 2

选择一个 0 到 360 之间的随机值来表示新的行进方向。该方向以角度表示,单位为度。我们把它加到当前角度。

// 3

方法Mathf.Repeat(currentAngle, 360)将循环值:currentAngle,使其永远不会小于 0,也不会大于 360。我们有效地将新角度保持在 0 到 360 度的范围内,然后用结果替换currentAngle

// 4

调用一个方法将角度转换成一个Vector3,并将结果添加到endPosition。变量endPosition将被Move()协程使用,我们很快就会看到。

角度到弧度到矢量!

该方法以度为单位获取一个角度参数,将其转换为弧度,并返回一个由ChooseNewEndpoint()使用的方向向量 3。

Vector3 Vector3FromAngle(float inputAngleDegrees)
{

// 1
    float inputAngleRadians = inputAngleDegrees * Mathf.Deg2Rad;

// 2
    return new Vector3(Mathf.Cos(inputAngleRadians), Mathf.Sin(inputAngleRadians), 0);
}

// 1

通过乘以角度到弧度的转换常量,将输入角度从角度转换为弧度。Unity 提供了这个常量,所以我们可以快速转换。

// 2

使用输入角度(以弧度为单位)创建敌人方向的归一化方向向量。

敌人行走动画

到目前为止,敌人只有一个动画:闲置。是时候利用我们在第三章创建的敌人行走动画剪辑了。

选择敌人预设,然后打开动画窗口,如图 8-3 所示。

img/464283_1_En_8_Fig3_HTML.jpg

图 8-3

选择了敌人对象的动画窗口

如果空闲状态是默认状态,它将显示为橙色。如果不是默认状态,右击“敌人-闲置-1”状态,选择:设置为层默认状态。

正如你所看到的,敌人-步行-1 状态是存在的,有一个动画剪辑,但目前没有被使用。计划是创建一个动画参数,并使用该参数在空闲和行走状态之间切换。

点击动画师参数部分的加号,选择 Bool,如图 8-4 所示。

img/464283_1_En_8_Fig4_HTML.jpg

图 8-4

选择 Bool 以创建类型为 Bool 的动画参数

将该参数命名为“isWalking”,如图 8-5 所示。

img/464283_1_En_8_Fig5_HTML.jpg

图 8-5

创建 isWalking Bool 参数

我们的漫游脚本将使用这个参数在空闲和行走之间切换敌人的动画状态。为了保持简单,行走动画将作为追逐玩家时跑步以及悠闲行走的替身。

右键点击敌人-闲置-1 状态并选择:进行转换。创建空闲状态和行走状态之间的转换。然后在行走状态和空闲状态之间创建另一个转换。当你完成后,动画状态窗口应该如图 8-6 所示。

img/464283_1_En_8_Fig6_HTML.jpg

图 8-6

创建空闲和行走状态之间的转换

点击从敌人-闲置-1 到敌人-步行-1 的转换状态,并使用以下设置,如图 8-7 所示。

img/464283_1_En_8_Fig7_HTML.jpg

图 8-7

过渡设置

点击从敌人-步行-1 到敌人-空闲-1 的转换,并使用图 8-7 中的相同设置进行配置。

设置每个过渡以使用我们刚刚创建的动画参数:isWalking。设置条件:isWalking 为真,如图 8-8 所示,从敌方空闲-1 过渡到敌方步行-1。

img/464283_1_En_8_Fig8_HTML.jpg

图 8-8

如果 isWalking == true,则满足此条件

在敌人-步行-1 到敌人-空闲-1 的转换中,将 isWalking 设置为 false。

就这样!敌人行走动画设置完毕。要使用新的动画状态,我们只需要在我们的Move()协程中将isWalking改为true,你很快就会看到。

在检查器中点击“应用”,将这些改变应用到所有敌人的预设上。

Move()协程

Move()协程负责将给定speed处的刚体 2D 从其当前位置移动到endPosition变量。

将以下方法添加到 Wander 脚本中。

public IEnumerator Move(Rigidbody2D rigidBodyToMove, float speed)
{

// 1
    float remainingDistance = (transform.position - endPosition).sqrMagnitude;

// 2
    while (remainingDistance > float.Epsilon)
    {

// 3
        if (targetTransform != null)
        {
            endPosition = targetTransform.position;
        }

// 4
        if (rigidBodyToMove != null)
        {

// 5
            animator.SetBool("isWalking", true);

// 6
            Vector3 newPosition = Vector3.MoveTowards(rigidBodyToMove.position, endPosition, speed * Time.deltaTime);

// 7
            rb2d.MovePosition(newPosition);

// 8
            remainingDistance = (transform.position - endPosition).sqrMagnitude;
        }

// 9
        yield return new WaitForFixedUpdate();
    }

// 10
    animator.SetBool("isWalking", false);
}

// 1

等式:(transform.position – endPosition)产生一个向量 3。我们使用一个名为sqrMagnitude的属性,它在 Vector3 类型上可用,来检索敌人当前位置和目的地之间的大致剩余距离。使用sqrMagnitude属性是 Unity 提供的执行快速矢量幅度计算的方法。

// 2

检查当前位置和终点位置之间的剩余距离是否大于等于零的float.Epsilon,

// 3

当敌人正在追击玩家时,值targetTransform将被设置为玩家变形而不是空值。然后我们覆盖了endPosition的原始值,使用targetTransform来代替。敌人移动的时候会朝着玩家移动,而不是朝着原来的endPosition移动。因为targetTransform实际上是玩家的变身,它会随着玩家新的位置不断更新。这使得敌人可以动态地跟随玩家。

// 4

Move()方法需要一个RigidBody2D,用它来移动敌人。在我们继续之前,确保我们确实有一个RigidBody2D要移动。

// 5

Bool类型的动画参数isWalking设置为true。这将启动状态转换到行走状态,并播放敌人行走动画。

// 6

Vector3.MoveTowards方法用于计算刚体 2D 的运动。它实际上并没有移动刚体 2D。该方法采用三个参数:当前位置、结束位置和在帧中移动的距离。记住变量:speed会变,取决于敌人是在追击还是悠闲的在场景周围徘徊。这个值将在追踪代码中改变,我们还没有写出来。

// 7

使用MovePosition()将刚体 2D 移动到新位置,在前一行中计算。

// 8

使用sqrMagnitude属性更新剩余距离。

// 9

直到下一次固定帧更新。

// 10

敌人已经到达endPosition等待选择新的方向,所以将动画状态改为空闲。

保存这个脚本并切换回 Unity 编辑器。

配置漫游脚本

选择敌人的预设,并配置漫游脚本,看起来像图 8-9 。将追踪速度设置为比漫游速度稍快的速度。方向改变间隔是漂移算法调用ChooseNewEndpoint()选择新的漂移方向的频率。

img/464283_1_En_8_Fig9_HTML.jpg

图 8-9

在漫游脚本中使用这些设置

在检查器中按“应用”,然后从层次视图中删除 EnemyObject。

现在按播放键。注意敌人是如何在场景中游荡的。如果玩家走近敌人,他们还不会追击她。接下来我们要添加追踪逻辑。

二维标记()

因此,除了追踪逻辑,我们已经实现了几乎所有的 Wander 算法。在这一节中,我们将编写一些简单的逻辑来插入游走算法,让敌人追击玩家。

追踪逻辑依赖于OnTriggerEnter2D()方法,每个单行为都提供了这个方法。正如我们在第五章中了解到的,触发碰撞器(设置了 Is Trigger 属性的碰撞器)可以用来检测另一个游戏对象进入碰撞器。当这种情况发生时,会对冲突中涉及的 MonoBehaviours 调用OnTriggerEnter2D()方法。

当玩家进入附属于敌人的 CircleCollider2D 时,敌人可以“看见”玩家,应该会追击她。

让我们写下这个逻辑。

void OnTriggerEnter2D(Collider2D collision)
{

// 1
    if (collision.gameObject.CompareTag("Player") && followPlayer)
    {

// 2
        currentSpeed = pursuitSpeed;

// 3
        targetTransform = collision.gameObject.transform;

// 4
        if (moveCoroutine != null)
        {
            StopCoroutine(moveCoroutine);
        }

// 5
        moveCoroutine = StartCoroutine(Move(rb2d, currentSpeed));
    }
}

// 1

检查碰撞中对象上的标签,查看它是否是 PlayerObject。还要检查followPlayer当前是否为真。该变量通过 Unity 编辑器设置,用于打开和关闭追踪行为。

// 2

在这一点上,我们已经确定collision和玩家在一起,所以将currentSpeed改为pursuitSpeed

// 3

设置targetTransform等于玩家的变换。Move()协程将检查targetTransform是否不为空,然后将其作为 endPosition 的新值。敌人就是这样不断的追击玩家,而不是漫无目的的游荡。

// 4

如果敌人正在移动,moveCoroutine将不会为空。需要在再次启动之前停止它。

// 5

因为endPosition现在被设置为玩家对象的变换,调用Move()会将敌人移向玩家。

2d control success()

如果敌人pursuitSpeed比玩家movementSpeed小,玩家可以跑得比任何敌人都快。随着玩家逃离敌人,她将退出敌人触发碰撞器,导致OnTriggerExit2D()被调用。当这种情况发生时,敌人实际上失去了玩家的视线,并继续漫无目的地游荡。

这种方法几乎与OnTriggerEnter2D()相同,只是做了一些调整。

void OnTriggerExit2D(Collider2D collision)
{

// 1
    if (collision.gameObject.CompareTag("Player"))
    {

// 2
        animator.SetBool("isWalking", false);

// 3
        currentSpeed = wanderSpeed;

// 4
        if (moveCoroutine != null)
        {
            StopCoroutine(moveCoroutine);
        }

// 5
        targetTransform = null;
    }
}

// 1

检查标签,看看玩家是否正在离开碰撞器。

// 2

敌人在失去玩家的视线后感到困惑,并停顿了一会儿。将isWalking设置为 false,将动画更改为 idle。

// 3

currentSpeed设置为wanderSpeed,下次敌人开始移动时使用。

// 4

因为我们希望敌人停止追击玩家,所以我们需要阻止moveCoroutine

// 5

敌人不再跟踪玩家,所以将targetTransform设置为null

保存这个脚本并返回到 Unity 编辑器。按播放。

将玩家移动到敌人的视野中,注意敌人将如何追击她,直到她跑出视野。

小发明

Unity 支持可视化调试和设置工具 Gizmos 的创建。这些工具是通过一组方法创建的,并且只出现在 Unity 编辑器中。当你的游戏在用户的硬件上编译和运行时,它们不会出现在你的游戏中。

我们将创建两个小发明来帮助可视化调试 Wander 算法。我们将创建的第一个小发明将显示圆形对撞机 2D 的电线轮廓,用于检测玩家何时在敌人的视线范围内。这个小发明将使人们更容易看到追逐行为应该何时开始。

将以下变量添加到 Wander 类的顶部,这里有其他变量:

CircleCollider2D circleCollider;

然后给Start()加上下面一行。它可以放在方法中的任何位置:

circleCollider = GetComponent<CircleCollider2D>();

这一行检索当前敌人对象的CircleCollider2D组件。我们将使用它在屏幕上画一个圆,直观地表示当前的圆形碰撞器。

要实现 Gizmo,实现 MonoBehaviour 提供的名为OnDrawGizmos()的方法:

void OnDrawGizmos()
{

// 1
    if (circleCollider != null)
    {

// 2
        Gizmos.DrawWireSphere(transform.position, circleCollider.radius);
    }
}

// 1

在我们尝试使用它之前,确保我们有一个圆形碰撞器的参考。

// 2

调用Gizmos.DrawWireSphere()并为其提供位置和半径,绘制一个球体。

保存脚本并返回到 Unity 编辑器。确保 Gizmos 按钮已按下,然后按播放。当敌人四处游荡时,注意敌人周围的小玩意,如图 8-10 所示。这个小控件的周长和位置对应于CircleCollider2D

img/464283_1_En_8_Fig10_HTML.jpg

图 8-10

代表敌人周围的CircleCollider2D的小发明

如果你没有看到圆形小控件出现,确保你在游戏窗口的右上角启用了小控件,如图 8-11 所示。

img/464283_1_En_8_Fig11_HTML.jpg

图 8-11

启用小控件

如果我们有一条显示敌人目的地的线,就更容易看到游走算法如何将敌人移向一个位置。让我们在屏幕上从当前敌人位置到终点位置画一条线。

我们将使用Update()方法,这样每一帧都会画出一条线。

void Update()
{
// 1
    Debug.DrawLine(rb2d.position, endPosition, Color.red);
}

// 1

当启用小控件时,方法Debug.DrawLine()的结果是可见的。该方法获取当前位置、结束位置和线条颜色。

在图 8-12 中我们可以看到,从敌人的中心到目的地(endPosition)画了一条红线。

img/464283_1_En_8_Fig12_HTML.jpg

图 8-12

从敌人阵地到终点画了一条红线

自卫

我们勇敢的玩家除了用智慧引导她和用弹弓防御之外,将一无所有。每按一次鼠标按钮,我们的玩家就会向鼠标点击的位置发射一发弹弓子弹。我们将编写弹药的行为脚本,这样当它在空中飞行时,它会沿着一条弧线而不是直线飞向目标。

需要的类别

我们需要三个不同职业的组合来给玩家保护自己的能力。

武器类将封装弹弓的功能。这个职业将附属于玩家预设,并负责一些不同的事情:

  • 确定何时按下鼠标按钮,并使用按钮按下的位置作为目标

  • 从当前动画切换到射击动画

  • 制造弹药并向目标移动

我们需要一个类来表示弹弓发射的弹药。这个弹药班将负责:

  • 确定附加的弹药游戏对象何时与敌人发生碰撞

  • 记录它与敌人碰撞时造成的伤害

我们还将构建一个 Arc 类,负责以夸张的弧线将弹药游戏对象从起始位置移动到结束位置。否则弹药会直线前进。

弹药等级

目前,我们希望游戏中的弹药只能伤害敌人,但是你也可以在将来很容易地扩展这个功能来伤害其他东西。每个 AmmoObject 将在 Unity 编辑器中显示一个属性,描述它造成的伤害。我们将把这个氨物体变成一个预制体。如果你想给玩家提供两种不同类型的弹药,创建第二个弹药预置,改变上面的精灵和造成的伤害是一个简单的任务。

在项目层次中创建一个新的游戏对象,并将其重命名为“AmmoObject”。我们将创建 AmmoObject,配置它,编写脚本,然后将它变成一个预置。

导入资产

从你下载的资源中,将标题为“Ammo.png”的 spritesheet 拖到资源➤ Sprites ➤对象文件夹中。

选择弹药 spritesheet,并在检查器中使用以下导入设置:

  • 纹理类型:精灵(2D 和用户界面)

  • 精灵模式:单个

  • 每单位像素:32

  • 过滤器模式:点(无过滤器)

  • 确保选择了底部的默认按钮,并将压缩设置为:无

按下应用按钮。

Unity 编辑器将自动检测精灵的边界,所以没有必要打开精灵编辑器或切片精灵。

添加组件,设置层

将 Sprite 渲染器组件添加到 AmmoObject。

在 Sprite 渲染器上,将排序层设置为:Characters,并将 Sprite 属性设置为:Ammo。弹药是我们刚刚进口的雪碧。

向 AmmoObject 添加 CircleCollider2D。确保选中“触发”设置,并将半径设置为 0.2。如果你需要调整碰撞器,点击编辑碰撞器按钮,移动手柄直到你满意碰撞器包围弹药精灵。

创建一个名为“弹药”的新层,并用它来设置 AmmoObject 上的层,如图 8-13 所示。

img/464283_1_En_8_Fig13_HTML.jpg

图 8-13

将层设置为:弹药

更新层碰撞矩阵

如果你还记得在第五章中,我们学习了基于层的碰撞检测。总而言之,只有当层碰撞矩阵被配置为相互感知时,不同层中的两个碰撞器才会相互作用。

进入编辑菜单➤项目设置➤物理 2D,配置图层碰撞矩阵,如图 8-14 。

img/464283_1_En_8_Fig14_HTML.jpg

图 8-14

配置弹药层

我们想让一个弹药碰撞机与一个敌人的碰撞机相互作用,但不与任何其他碰撞机相互作用。回到第五章,我们配置了敌人来使用敌人层,我们也配置了 AmmoObject 来使用弹药层。

构建弹药脚本

给 AmmoObject 添加一个名为“弹药”的新脚本。在 Visual Studio 中打开弹药脚本。

使用下面的代码来构建弹药类。

using UnityEngine;

public class Ammo : MonoBehaviour
{

// 1
    public int damageInflicted;

// 2
    void OnTriggerEnter2D(Collider2D collision)
    {
// 3
if (collision is BoxCollider2D)
        {

// 4
            Enemy enemy = collision.gameObject.GetComponent<Enemy>();

// 5
            StartCoroutine(enemy.DamageCharacter(damageInflicted, 0.0f));

// 6
            gameObject.SetActive(false);
        }

    }
}

// 1

弹药对敌人造成的伤害。

// 2

当另一个物体进入弹药游戏物体的触发碰撞器时调用。触发器碰撞器只是一个设置了:Is Trigger 属性的碰撞器。在这种情况下,它是一个CircleCollider2D

// 3

重要的是检查我们是否击中了敌人内部的BoxCollider2D。记住敌人也有一个CircleCollider2D,它在漫游脚本中用来探测玩家是否在附近。BoxCollider2D是我们用来探测与敌人实际碰撞的物体的对撞机。

// 4

collision中检索gameObject的敌方脚本组件。

// 5

启动协程来伤害敌人。如果您还记得第七章中的,那么DamageCharacter()的方法签名如下所示:

DamageCharacter(int damage, float interval)

第一个参数:damage,是对敌人造成的伤害量。第二个参数:interval,是施加damage之间等待的时间。通过interval = 0 将造成damage一次。我们将变量damageInflicted作为第一个参数传递,它是弹药类的一个实例变量,将通过 Unity 编辑器设置。

// 6

因为弹药已经击中了敌人,所以将 AmmoObject 的gameObject设置为非活动状态。

为什么我们要将gameObject设置为非活动状态,而不是调用Destroy(gameObject)并完全删除它?

好问题——很高兴你问了。我们将 AmmoObject 设置为非活动状态,这样我们就可以使用一种叫做对象池的技术来保持游戏的良好性能。

在我们忘记之前...使氨对象成为预设的

在我们进入对象池之前,最后一件事——让我们把 AmmoObject 变成一个预置。遵循同样的过程,我们总是用游戏对象来创建预置:

  1. 从层次视图拖动一个对象到预设文件夹来创建一个预设。

  2. 从层次视图中删除原始 AmmoObject。

对象池

如果你的游戏有大量的对象在短时间内被实例化然后销毁,你可能会看到游戏暂停,速度变慢,整体性能下降。这是因为在 Unity 中实例化和销毁对象比简单地激活和停用对象更消耗性能。销毁一个对象将调用 Unity 的内部内存清理过程。短时间内重复调用这个过程,尤其是在内存受限的环境中,比如移动设备或 web,会影响性能。这些对性能的影响不会随着对象数量的减少而显现出来,但是如果你的游戏需要制造大量的敌人或子弹,你就需要考虑一个更优化的方法。

为了避免与对象创建和销毁相关的性能问题,我们将使用一种称为对象池的优化技术。要使用对象池,请提前为场景预实例化一个对象的多个副本,取消激活它们,然后将它们添加到对象池中。当场景需要一个对象时,遍历对象池并返回找到的第一个非活动对象。当场景使用完该对象后,将其置于非活动状态,并将其返回到对象池,以便场景将来重用。

简而言之,对象池重用对象,最大限度地减少由于运行时内存分配和清理导致的性能下降。对象最初将被设置为非活动状态,只有在使用时才被激活。当使用一个对象完成场景时,该对象再次被设置为非活动状态,表明它可以在需要时被重用。

通过反复点击鼠标按钮,弹弓武器将快速连续发射多发子弹。这是一个教科书式的场景,对象池可以提高运行时性能。

以下是在 Unity 中使用对象池的三个关键步骤:

  • 在需要对象之前,预先实例化对象的集合(一个“池”),并将其设置为非活动状态

  • 当游戏需要一个对象时,不要实例化一个新的对象,从池中抓取一个不活动的对象并激活它

  • 使用完对象后,只需将其置于非活动状态,即可将其放回池中

建造武器类

我们将在武器类中创建并存储弹药对象池。如前所述,这个类将包含弹弓功能,并最终控制显示玩家发射弹弓的动画。

我们将通过创建用来存放弹药的对象池来开始构建基本的弹弓功能。

选择 PlayerObject 预设,并添加一个名为“武器”的新脚本。在 Visual Studio 中打开此脚本。使用下面的代码开始构建武器类。

// 1
using System.Collections.Generic;
using UnityEngine;

// 2
public class Weapon : MonoBehaviour
{

// 3
    public GameObject ammoPrefab;

// 4
    static List<GameObject> ammoPool;

// 5
    public int poolSize;

// 6
    void Awake()
    {

// 7
        if (ammoPool == null)
        {
            ammoPool = new List<GameObject>();
        }

// 8
        for (int i = 0; i < poolSize; i++)
        {
            GameObject ammoObject = Instantiate(ammoPrefab);
            ammoObject.SetActive(false);
            ammoPool.Add(ammoObject);
        }
    }
}

// 1

我们需要导入System.Collections.Generic,这样我们就可以使用List数据结构。List类型的变量将用于表示对象池——预实例化对象的集合。

// 2

武器继承自MonoBehaviour,因此可以附加到游戏对象上。

// 3

属性ammoPrefab将通过 Unity 编辑器设置,并用于实例化 AmmoObject 的副本。这些副本将被添加到Awake()方法中的对象池中。

// 4

类型为List的属性ammoPool用于表示对象池。

C# 中的List是强类型对象的有序集合。因为它们是强类型的,所以您必须提前声明List将保存什么类型的对象。试图插入任何其他类型的对象将导致编译时出错,您的游戏将无法运行。这个List被宣布只持有GameObjects

变量ammoPool是一个静态变量。如果您回忆一下第七章中的,static变量属于类本身,并且只有一个副本存在于内存中。

// 5

属性允许我们设置对象池中预实例化的对象数量。因为这个属性是public,所以可以通过 Unity 编辑器进行设置和调整。

// 6

创建对象池和预初始化 AmmoObjects 的代码将包含在Awake()方法中。Awake()在脚本的生命周期中被调用一次:当脚本被加载时。

// 7

检查ammoPool对象池是否已经初始化。如果它还没有被初始化,创建一个新的类型为ListammoPool来保存GameObjects

// 8

使用poolSize作为上限创建一个循环。在循环的每次迭代中,实例化一个新的ammoPrefab副本,将其设置为非活动的,并将其添加到ammoPool

对象池(ammoPool)已经创建好,可以在场景中使用了。你很快就会看到,每当玩家用弹弓发射弹药时,我们会从ammoPool中抓取一个不活动的 AmmoObject 并激活它。当场景使用 AmmoObject 完成后,它被停用并返回到ammoPool

根除方法

方法存根是尚未开发的代码的替代品。它们还有助于找出特定功能所需的方法。让我们为其余的基本武器功能列出我们需要的各种方法。

将以下代码添加到武器类中。

// 1
    void Update()
    {

// 2
        if (Input.GetMouseButtonDown(0))
        {

// 3
            FireAmmo();
        }
    }

// 4
    GameObject SpawnAmmo(Vector3 location)
    {
       // Blank, for now...
    }

// 5
    void FireAmmo()
    {
       // Blank, for now...
    }

// 6
    void OnDestroy()
    {
        ammoPool = null;
    }

// 1

Update()方法中,检查每一帧,看看用户是否点击了鼠标来发射弹弓。

// 2

GetMouseButtonDown()方法是输入类的一部分,接受单个参数。这个方法将检查鼠标左键是否被点击和释放。方法参数0表示我们对第一个(左)鼠标按钮感兴趣。如果我们对鼠标右键感兴趣,我们将传递值:1

// 3

因为已经单击了鼠标左键,所以调用我们将要编写的FireAmmo()方法。

// 4

SpawnAmmo()方法将负责从对象池中检索并返回一个 AmmoObject。该方法采用一个参数:location,指示实际放置检索到的 AmmoObject 的位置。SpawnAmmo()返回一个GameObject——从ammoPool对象池中检索到的激活的 AmmoObject。

// 5

FireAmmo()将负责将 AmmoObject 从在SpawnAmmo()中产生的起始位置移动到鼠标按钮被点击的结束位置。

// 6

设置ammoPool = null销毁对象池并释放内存。OnDestroy()方法是MonoBehaviour自带的,当附属的GameObject被销毁时会被调用。

产卵弹药法

SpawnAmmo 方法将遍历预先实例化的 AmmoObjects 的集合或“池”,并找到第一个非活动对象。然后它将激活 AmmoObject,设置transformposition,然后返回 AmmoObject。如果不存在非活动的 AmmoObjects,则返回null。因为弹药池是用设定数量的 AmmoObjects 初始化的,所以一次可以出现在屏幕上的 AmmoObjects 的数量有一个固有的限制。这个限制可以通过改变 Unity 编辑器中的poolSize来调整。

小费

找出在对象池中预实例化的对象的理想数量的最好方法是经常玩这个游戏,然后相应地调整这个数量。

让我们在武器类中实现 SpawnAmmo()方法。

    public GameObject SpawnAmmo(Vector3 location)
    {

// 1
        foreach (GameObject ammo in ammoPool)
        {

// 2
            if (ammo.activeSelf == false)
            {

// 3
                ammo.SetActive(true);

// 4
                ammo.transform.position = location;

// 5
                return ammo;
            }
        }
// 6
        return null;
    }

// 1

在预先实例化的对象池中循环。

// 2

检查当前对象是否处于非活动状态。

// 3

我们发现了一个不活动的对象,所以将其设置为活动的。

// 4

将对象上的transform.position设置为参数:location。当我们调用SpawnAmmo()时,我们将传递一个location,让它看起来像是从弹弓中射出的氨物体。

// 5

返回活动对象。

// 6

找不到非活动对象,因此当前正在使用池中的所有对象。返回null

弧类和线性插值

Arc 脚本将负责实际移动 AmmoObject。我们希望弹药沿着弧线飞向目标。我们将创建一个名为“Arc”的新 MonoBehaviour 来包含此功能。因为我们将 Arc 创建为一个独立的 MonoBehaviour,所以我们可以在将来将这个脚本附加到其他游戏对象上,使它们也能以弧形运行。

为了简单起见,我们将首先实现沿直线行进的 Arc 脚本。在我们做好工作后,我们将添加一个小的调整来使弹药以一个好看的弧线运行。

在项目视图中选择 AmmoObject 预设,并添加一个名为“Arc”的新脚本。在 Visual Studio 中打开 Arc 脚本,并编写以下代码:

using System.Collections;
using UnityEngine;

// 1
public class Arc : MonoBehaviour
{

// 2
    public IEnumerator TravelArc(Vector3 destination, float duration)
    {

// 3
        var startPosition = transform.position;

// 4
        var percentComplete = 0.0f;

// 5
        while (percentComplete < 1.0f)
        {

// 6
            percentComplete += Time.deltaTime / duration;

// 7
            transform.position = Vector3.Lerp(startPosition, destination, percentComplete);

// 8
            yield return null;
        }
// 9
        gameObject.SetActive(false);
    }
}

// 1

因为 Arc 是一个单体行为,所以它可以附加到游戏对象上。

// 2

是沿着弧线移动游戏对象的方法。将TravelArc()设计成协程是有意义的,因为它将在几个帧的过程中执行。TravelArc()带两个参数:destinationduration。定义如下:destination是结束位置,duration是将附属gameObject从起始位置移动到destination所需的时间。

// 3

抓取当前游戏对象的transform.position并将其分配给startPosition。我们将在位置计算中使用startPosition

// 4

percentComplete用于本方法后面使用的Lerp或线性插值计算。我们将解释它的用法。

// 5

检查percentComplete是否小于 1.0。把 1.0 想象成 100%的十进制形式。我们只希望这个循环运行到percentComplete是 100%。当我们在下一行解释线性插值时,这将是有意义的。

// 6

我们希望将 AmmoObject 平稳地移向它的目的地。每一帧弹药移动的距离取决于我们希望移动持续的时间,以及已经过去的时间。

自上一帧以来经过的时间量除以运动的总期望持续时间,等于总持续时间的百分比。

Take a look at this line again: percentComplete += Time.deltaTime / duration;

Time.deltaTime是自绘制最后一帧以来经过的时间。这一行中的结果:percentageComplete,是我们将总持续时间的百分比与之前完成的百分比相加得到的结果,从而得到到目前为止已经完成的持续时间的总百分比。

我们将在下一行中使用这个总完成百分比来平滑地移动 AmmoObject。

// 7

为了实现一个效果,即一个物体以恒定的速度在两点之间平滑移动,我们使用了一种在游戏编程中广泛使用的技术,叫做线性插值。线性插值需要起始位置、结束位置和百分比。当我们使用线性插值来确定每帧要行进的距离时,线性插值方法的百分比参数:Lerp(),是完成时长的百分比(percentComplete))。

Lerp()方法中使用持续时间percentComplete意味着无论我们在哪里发射 AmmoObject,都需要相同的时间到达那里。这对于现实世界的模拟来说显然是不现实的,但是对于电子游戏来说,我们可以暂停现实世界的规则。

基于这个百分比,Lerp()方法将返回起点和终点之间的一个点。我们将结果赋给 AmmoObject 的transform.position

// 8

暂停协程的执行,直到下一帧。

// 9

如果电弧已经到达其目的地,关闭附加的gameObject

别忘了保存这个脚本!

屏幕点数和世界点数

在我们写下一个方法之前,我们应该谈谈屏幕点和世界点。

屏幕空间是屏幕上实际可见的空间,以像素为单位定义。例如,我们的屏幕空间目前是 1280 × 720 或水平 1280 像素垂直 720 像素。

世界空间是真实的游戏世界,没有大小限制。它的大小理论上是无限的,用单位来定义。当我们在第四章中设置 PPU 时,我们配置了摄像机来将世界单位映射到屏幕单位。

当我们在游戏中移动物体时,因为它们可以移动到任何地方,而不仅限于在屏幕上移动,所以我们相对于世界空间移动它们。Unity 提供了一些从屏幕转换到世界空间的简便方法。

火弹药法

现在我们已经构建了移动 AmmoObject 的 Arc 组件,切换回武器类,让我们使用下面的代码实现FireAmmo()方法。

首先,将下面的变量添加到武器类的顶部,在poolSize变量之后。这个变量将用于设置弹弓发射弹药的速度:

public float weaponVelocity;

然后使用下面的代码实现FireAmmo()方法:

    void FireAmmo()
    {

// 1
        Vector3 mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);

// 2
        GameObject ammo = SpawnAmmo(transform.position);

// 3
        if (ammo != null)
        {

// 4
            Arc arcScript = ammo.GetComponent<Arc>();

// 5
             float travelDuration = 1.0f / weaponVelocity;

// 6
            StartCoroutine(arcScript.TravelArc(mousePosition, travelDuration));
        }
    }

// 1

因为鼠标使用屏幕空间,我们将鼠标位置从屏幕空间转换到世界空间。

// 2

通过SpawnAmmo()方法从弹药对象池中获取一个激活的 AmmoObject。传递当前武器的transform.position作为取回 AmmoObject 的起始位置。

// 3

检查以确保SpawnAmmo()返回了一个 AmmoObject。记住,如果所有预实例化的对象都已经被使用了,那么SpawnAmmo()可能会返回null

// 4

检索对 AmmoObject 的Arc组件的引用,并将其保存到变量arcScript

// 5

数值weaponVelocity将在 Unity 编辑器中设置。用 1.0 除以weaponVelocity得到一个分数,我们将把这个分数用作一个氨物体的移动持续时间。例如,1.0 / 2.0 = 0.5,所以弹药将需要半秒钟穿过屏幕到达目的地。

这个公式的结果是,当目的地较远时,弹药的速度会加快。想象一个玩家向附近的东西开火的场景。如果我们不能保证无论距离远近,飞行时间总是 0.5 秒,那么子弹很可能会从弹弓中快速射向敌人,以至于你真的看不到它。如果我们制作一个第一人称射击游戏,这可能是好的。但是在我们的 RPG 中,我们希望随时都能看到弹弓发射的弹药。这样看起来更“有趣”。

// 6

调用我们之前在arcScript写的 TravelArc 方法。召回方法签名:TravelArc(Vector3 destination, float duration)。对于destination参数,传递鼠标点击的位置。对于duration参数,传递我们在前一行中计算的travelDuration that:

float travelDuration = 1.0f / weaponVelocity;

回想一下,TravelArc()中的duration参数用于确定 AmmoObject 从起始位置移动到destination需要多长时间。我们将在下一步配置武器脚本时设置weaponVelocity的值。

配置武器脚本

我们快完成了!在玩家使用弹弓之前,还需要整理一些东西。保存武器脚本,切换到 Unity 编辑器,并选择 PlayerObject。因为我们已经将武器脚本添加到 PlayerObject 中,所以将 AmmoObject 预设拖到武器脚本的弹药预设属性中。如图 8-15 所示,设置池大小为 7,武器速度为 2。

img/464283_1_En_8_Fig15_HTML.jpg

图 8-15

配置武器脚本

我们选择用0.5来表示武器的速度,因为这感觉像是弹弓子弹飞行的自然时间。你可以随意调整这个值,让它看起来自然有趣。

我们准备好出发了。按下 Play 并点击一个敌人发射弹弓和像素化的死亡雨。

太棒了!弹弓发射弹药,但它不是以弧线运行。让我们解决这个问题。

形成电弧

切换回 Visual Studio 中的 Arc 脚本。我们将稍微调整一下脚本,使弧线脚本名副其实,实际上是沿着弧线轨迹行进。

修改 Arc 脚本中的while()循环,如下所示:

    while (percentComplete < 1.0f)
    {
                 // Leave this existing line alone.
                 percentComplete += Time.deltaTime / duration;

// 1
        var currentHeight = Mathf.Sin(Mathf.PI * percentComplete);

// 2
        transform.position = Vector3.Lerp(startPosition, destination, percentComplete) + Vector3.up * currentHeight;

                 // Leave these existing lines alone.
        percentComplete += Time.deltaTime / duration;
        yield return null;
    }

// 1

为了理解这里发生的事情,我们需要一点高中三角学的知识。波的“周期”是完成一个完整周期所需的时间。根据图 8-16 ,正弦波的周期为(2 * π),正弦波的一半周期正好为(π)。

img/464283_1_En_8_Fig16_HTML.jpg

图 8-16

正弦曲线

通过将(percentComplete × Mathf.PI)的结果传递给正弦函数,我们有效地每隔duration秒沿着正弦曲线移动 PI 距离。结果被分配给currentHeight

// 2

Vector3.up是单位提供的变量,表示 Vector3(0,1,0)。将Vector3.up * currentHeightVector3.Lerp()的结果相加,调整位置,使 AmmoObject 不再沿直线移动,而是沿 Y 轴向上然后向下朝着endPosition移动。

保存脚本,返回 Unity 编辑器,然后按 Play。发射弹弓,注意它是如何以弧线飞行的。

你会注意到,当玩家发射弹弓时,我们实际上并没有播放任何类型的射击动画。我们将在下一节中解决这个问题。

制作弹弓动画

我们已经创建了一个武器,并编写了开火的代码,但玩家看起来有点奇怪,因为她只是站在那里,看着弹药神秘地出现并飞向目标。在这一节中,我们将构建播放玩家发射弹弓的动画的功能。您还将学习一种简化动画状态管理的新方法。

为了简单起见,我们首先将这种新的状态管理方法应用于行走动画,因为我们已经熟悉了状态机的工作方式,以及动画应该是什么样子。一旦我们适应了新的方法,我们将把它应用于发射弹弓。

动画和混合树

回到第三章,我们为玩家设置了一个动画状态机,由包含动画剪辑的动画状态组成。这些状态通过转换连接起来,我们通过在 Animator 组件上设置动画参数来控制转换。

玩家的状态机目前类似于图 8-17 。

img/464283_1_En_8_Fig17_HTML.jpg

图 8-17

播放器动画状态机

因为玩家可以向四个不同的方向行走,所以她也可以向四个不同的方向发射弹弓。如果我们为四个发射方向添加另外四个动画状态,这个状态机将开始看起来相当拥挤。如果我们最终想要向状态机添加更多的状态,事情将很快变得难以管理,视觉上令人困惑,并降低整体开发速度。

幸运的是,Unity 为我们提供了一个解决方案—输入:混合树。

混合树木

游戏编程经常需要在两个动画之间混合,例如当一个角色在行走时,然后逐渐开始奔跑。混合树可用于将多个动画平滑地混合成一个平滑的动画。虽然我们不会在游戏中混合多个动画,但是混合树还有一个我们将要用到的次要用途。

当用作动画状态机的一部分时,混合树可用于平滑地从一个动画状态过渡到另一个动画状态。混合树可以将各种动画捆绑到一个节点中,使您的游戏架构更加清晰和易于管理。混合树由在 Unity 编辑器中配置并在代码中设置的变量控制。

我们将创建两个混合树。由于我们已经熟悉了行走动画状态机,我们创建的第一个混合树将用于重新创建行走状态。我们还将更新玩家的 MovementController 代码来使用这个混合树。重建熟悉的东西将是一个很好的方式来适应混合树。

一旦我们有了行走混合树,我们将添加四个射击状态作为他们自己的射击混合树,并更新武器类来使用射击混合树。

清理动画师

是时候告别陈旧的动画状态管理方式了。选择 PlayerObject,打开 Animator 视图。从动画状态机中删除四个原始玩家行走状态。移除任何状态和空闲状态之间的转换,因为我们也不再需要它了。

当你完成后,动画视图应该如图 8-18 所示。

img/464283_1_En_8_Fig18_HTML.jpg

图 8-18

移除了旧玩家行走状态的动画视图

我们将创建一个混合树节点,作为其中各种行走动画状态的容器。包含所有四个行走动画的混合树节点将在 Animator 视图中显示为单个节点。可以想象,随着状态数量的增加,这种方法使得开发人员更容易可视化和管理状态。

构建行走混合树

  1. 在 Animator 窗口中右键单击并选择:从新混合树创建状态➤。

  2. 选择创建的混合节点,并在检查器中将其名称更改为:“行走树”。

  3. 双击“行走树”节点以查看混合树图形。

混合树应该如图 8-19 所示。

img/464283_1_En_8_Fig19_HTML.jpg

图 8-19

空混合树形图

img/464283_1_En_8_Fig22_HTML.jpg

图 8-22

混合树中包含四个动画剪辑的四个运动

  1. 再添加三个动作,并添加以下动画剪辑:玩家走南、玩家走西、玩家走北,如图 8-22 所示。

img/464283_1_En_8_Fig21_HTML.jpg

图 8-21

在运动中使用 player-walk-east 动画剪辑

  1. 在“选择运动选择器”打开的情况下,选择 player-walk-east 动画剪辑。运动现在应该如图 8-21 所示。

img/464283_1_En_8_Fig20_HTML.jpg

图 8-20

单击点以打开“选择运动选择器”

  1. 选择混合树节点,并将检查器中的混合类型更改为:2D 简单方向。完成混合树的配置后,我们将讨论更多的混合类型。

  2. 选择混合树节点,右键单击,然后选择:添加运动。一个动作保存一个动画剪辑的引用和相应的输入参数。当我们为过渡使用混合树时,输入参数用于确定应该播放什么运动。

  3. 在检查器中,单击我们刚刚添加的运动旁边的点(图 8-20 )以打开选择运动选择器。

添加完所有四个动作后,动画窗口应该如图 8-23 所示。每个运动都显示为混合树节点的子节点。

img/464283_1_En_8_Fig23_HTML.jpg

图 8-23

具有四个运动节点的混合树,包含动画剪辑

一层层,一直往下

我们在这里所做的是将所有四个动画状态包装到一个容器中——一个混合树节点。该混合树节点位于基础层的子层内。如果你点击 Animator 视图左上角的基础层按钮,如图 8-24 所示,Animator 视图将返回到“基础层”并显示一个混合树节点。当使用动画师时,如果适合你的架构,你可以在层内嵌套层。

img/464283_1_En_8_Fig24_HTML.jpg

图 8-24

单击“基础层”按钮返回到基础 Animator 视图

正如我们在图 8-25 中看到的,这种简化的管理状态的方法将使你的游戏架构在未来保持整洁和易于管理。行走混合树是动画器中的一个节点。

img/464283_1_En_8_Fig25_HTML.jpg

图 8-25

Animator 中带有单个混合树(行走树)节点的基础层

关于混合类型的注记

混合类型用于描述混合树应该如何混合运动。如你所知,我们实际上并没有混合运动,所以术语混合类型有点误导。我们在它们之间转换,所以我们配置了混合树来使用 2D 简单方向混合。这种混合类型有两个参数,最适合表示不同方向的动画,例如向北走、向南走等等。因为我们使用混合树在向北、向南、向东和向西行走之间进行过渡,所以 2D 简单方向混合非常适合我们的用例。

动画参数

我们过去曾经使用过动画参数,当我们第一次为播放器配置动画状态机并创建“Animation State”参数时。

删除 Animator 窗口左侧的 AnimationState 参数。我们已经删除了依赖它的动画过渡。我们将用混合树和它自己的参数替换这个参数和相关的状态。这些参数将用于我们将在武器类中编写的代码中。

创建这三个动画参数。大写很重要,因为我们将在代码中引用它们:

  • 类型的 is walking:Bool

  • xDir 类型:Float

  • yDir 类型:Float

参数:Blend 是在创建动画师时创建的。请随意删除该参数,因为我们不需要它。

动画师的动画参数部分应该如图 8-26 所示。

img/464283_1_En_8_Fig26_HTML.jpg

图 8-26

行走混合树的新动画参数

小费

创建动画参数时,一个常见的错误来源是使用错误的数据类型创建它们。

使用参数

选中混合树,从检查器的下拉菜单中选择 xDir 和 yDir 参数,如图 8-27 所示。我们将在下一步中使用这两个参数。

img/464283_1_En_8_Fig27_HTML.jpg

图 8-27

从下拉菜单中选择参数:xDir 和 yDir

选择“混合树”节点后,查看检查器中参数下方的可视化窗口。将多个运动添加到混合树后,可视化窗口将自动出现。

想象一个(0,0)穿过窗口中心的笛卡尔坐标平面(图 8-28 )。四个坐标(1,0)、(0,-1)、(-1,0)和(0,1)可以相应地映射到下面的虚线的末端。可视化窗口的目的是帮助开发人员可视化配置。

img/464283_1_En_8_Fig28_HTML.jpg

图 8-28

想象一个笛卡尔坐标平面

在图 8-28 中,有四个蓝点聚集在 0,0 处,你看不到它们,因为它们被红色的中心点遮住了。这些点中的每一个都代表了我们之前添加的四个动作中的一个。

设置第一个动作的位置 X 和位置 Y,使代表玩家向东走动作的蓝点位于位置:(1,0),如图 8-29 所示。

img/464283_1_En_8_Fig29_HTML.jpg

图 8-29

为所有四个动作设置位置 X 和 Y

我们还想相应地设置其他三个运动的 X 和 Y 位置。例如,玩家向南行走的运动位置应该设置为(0,-1)。如图 8-29 所示设置所有四个动作的位置。

好吧,但是为什么是

因此,我们已经设置了混合树来使用我们的动画参数,并注意为每个动作设置位置 X 和位置 Y,但是来说是什么呢?

正如我们在本节开始时提到的,我们可以通过在 animator 组件上设置变量来管理混合树中的 2D 状态转换。这就好比我们在第三章动画状态机上设置变量一样。

换句话说,要使用混合树,我们将编写类似下面的代码。目前不要在任何类中编写这段代码——这只是出于说明的目的。

// 1
movement.x = Input.GetAxisRaw("Horizontal");
movement.y = Input.GetAxisRaw("Vertical");

// 2
animator.SetBool("isWalking", true);

// 3
animator.SetFloat("xDir", movement.x);
animator.SetFloat("yDir", movement.y);

// 1

从用户那里获取输入值。变量:movement的类型为:Vector2

// 2

设置动画参数:isWalking,表示玩家正在行走。这将过渡到行走混合树。

// 3

设置混合树用于过渡到特定运动的动画参数。这些是类型:Float,因为机芯Vector2包含了Floats

当用户向右按压时,输入值将是(0,1)。我们在 Animator 上设置这个,混合树播放玩家向右走的动画剪辑。

循环时间

选择混合树的四个子节点中的每一个,如果默认情况下没有选中,检查循环时间属性,如图 8-30 所示。该属性告诉动画制作者在这种状态下连续循环播放动画剪辑。

img/464283_1_En_8_Fig30_HTML.jpg

图 8-30

检查循环时间属性

如果我们不选中这个框,动画将播放一次,然后停止。

创建过渡

最后但同样重要的是,我们需要创建空闲状态和新的行走混合树之间的过渡。

右键单击动画器中的空闲状态节点,并选择:进行过渡。将过渡连接到行走混合树。选择过渡并使用以下设置:

  • 具有退出时间:未选中

  • 固定持续时间:未选中

  • 过渡持续时间:0

  • 过渡偏移:0

  • 中断源:无

使用我们创建的isWalking变量创建一个条件。设置为:true

在行走混合树和空闲状态之间创建另一个过渡。选择过渡并使用与前面相同的设置,除了当您创建isWalking条件时,将其设置为:false

更新运动控制器

是时候使用行走混合树了。打开 MovementController 类。

从 MovementController 中删除以下所有代码,因为我们不再需要它:

string animationState = "AnimationState";

并删除整个CharStates枚举:

enum CharStates
{
    walkEast = 1,
    walkSouth = 2,
 // etc
}

将现有的UpdateState()方法替换为:

void UpdateState()
{

// 1
    if (Mathf.Approximately(movement.x, 0) && Mathf.Approximately(movement.y, 0))
    {

// 2
        animator.SetBool("isWalking", false);
    }
    else
    {

// 3
        animator.SetBool("isWalking", true);
    }

// 4
    animator.SetFloat("xDir", movement.x);
    animator.SetFloat("yDir", movement.y);
}

// 1

检查运动向量是否大约等于 0,表明玩家是静止不动的。

// 2

因为玩家是站着不动的,所以把isWalking设为false

// 3

否则movement.xmovement.y,或者两者都是非零数字,说明玩家在移动。

// 4

用新的移动值更新animator

保存这个脚本并切换回 Unity 编辑器。按下播放键,让玩家在场景中走动。您已经摆脱了旧的动画状态,并使用混合树重建了行走动画。

导入战斗精灵

第一步是导入用于玩家战斗动画的精灵。将名为“PlayerFight32x32.png”的 spritesheet 拖动到 sprites 播放器文件夹中。

选择玩家战斗画面,并在检查器中使用以下导入设置:

  • 纹理类型:精灵(2D 和用户界面)

  • 精灵模式:多重

  • 每单位像素:32

  • 过滤器模式:点(无过滤器)

  • 确保选择了底部的默认按钮,并将压缩设置为:无

按下应用按钮,然后打开精灵编辑器。

从“切片”菜单中,选择“按单元大小划分网格”,并将像素大小设置为 32。按下应用并关闭精灵编辑器。

创建动画剪辑

下一步是创建动画剪辑。在前面的章节中,我们通过为动画的每一帧选择精灵来创建动画剪辑,然后将它们拖动到游戏对象上。Unity 会自动创建一个动画剪辑并添加一个动画控制器(如果还没有的话)。

这次我们将创建一个稍微不同的动画剪辑,因为我们将创建一个混合树来管理动画。

转到精灵➤播放器文件夹,展开我们刚刚切片的精灵工作表。选择前四帧,如图 8-31 所示。这些精灵对应于玩家拉回弹弓并发射它。

img/464283_1_En_8_Fig31_HTML.jpg

图 8-31

在项目视图中选择前四个玩家战斗精灵

右键选择创建➤动画,如图 8-32 所示。

img/464283_1_En_8_Fig32_HTML.jpg

图 8-32

手动创建动画

将创建的动画重命名为:“玩家-火-东”。选择接下来的四个精灵,并遵循相同的步骤。将生成的动画命名为:“玩家-火-西”。

开火北动画只有两帧:“PlayerFight32x32_8”和“PlayerFight32x32_9”。使用这些帧来创建“玩家-火-北”。

击南动画有三帧:“PlayerFight32x32_10”、“PlayerFight32x32_11”、“PlayerFight32x32_12”。使用那些帧来创建“玩家-火-南方”。

将我们刚刚创建的所有动画剪辑移动到动画➤动画文件夹。

建立战斗混合树

  1. 不要选中混合树子节点中的循环时间框。我们只想播放一次射击动画。

  2. 创建空闲状态和新的火焰混合树之间的过渡。选择过渡并使用以下设置:

    • 具有退出时间:未选中

    • 固定持续时间:未选中

    • 过渡持续时间:0

    • 过渡偏移:0

    • 中断源:无

img/464283_1_En_8_Fig34_HTML.jpg

图 8-34

为每个动作设置位置 X 和位置 Y

  1. 为每个动作设置位置 X 和位置 Y,如图 8-34 所示。

img/464283_1_En_8_Fig33_HTML.jpg

图 8-33

配置动画参数

  1. 在 Animator 窗口中右键单击并选择:从新混合树创建状态➤。

  2. 选择创建的混合节点,并在检查器中将其名称更改为:“火树”。

  3. 双击“火焰树”,在它自己的层上查看混合树图形。

  4. 选择混合树节点,并将检查器中的混合类型更改为:2D 简单方向。

  5. 选择混合树节点,右键单击,然后选择:添加运动。

  6. 在检查器中,单击我们刚刚添加的运动旁边的点,以打开“选择运动选择器”。

  7. 选择 player-fire-east 动画剪辑。

  8. 再添加 3 个动作,并添加 player-fire-south、player-fire-west 和 player-fire-north 的动画剪辑。

  9. 创建以下动画参数:isFiring(类型:Bool)、fireXDir(类型:Float)、fireYDir(类型:Float),删除混合参数。

  10. 配置混合树使用下拉框中的动画参数,如图 8-33 所示。

使用我们创建的isFiring变量在转换中创建一个条件。设置为:true

  1. 在火焰混合树和空闲状态之间创建另一个过渡。选择过渡并使用与前面相同的设置,但有两处不同:
    • 创建isFiring条件时,将其设置为:false

    • 检查退出时间属性,并将退出时间的值设置为:1。

退出时间

过渡的“退出时间”属性用于告诉动画制作人,在动画播放了多少百分比之后,过渡才会生效。通过将“开火➤”空闲过渡的“退出时间”属性设置为:1,我们说我们希望在过渡前播放 100%的开火动画。

更新武器等级

下一步是更新武器类,以利用我们刚刚建立的火焰混合树。

RequireComponent属性添加到武器类的顶部:

[RequireComponent(typeof(Animator))]
public class Weapon : MonoBehaviour

我们将要添加的代码需要一个 Animator 组件,所以要确保总有一个可用的组件。

添加变量

我们需要一些额外的变量来激活玩家。将以下变量添加到武器类的顶部。

// 1
bool isFiring;

// 2
[HideInInspector]
public Animator animator;

// 3
Camera localCamera;

// 4
float positiveSlope;
float negativeSlope;

// 5
enum Quadrant
{
    East,
    South,
    West,
    North
}

// 1

描述玩家是否正在发射弹弓。

// 2

[HideInInspector]属性与public访问器一起使用,这样就可以从这个类的外部访问 animator,但它不会显示在检查器中。没有理由在检查器中显示animator,因为我们计划以编程方式检索对 Animator 组件的引用。

// 3

使用localCamera保存一个对摄像机的引用,这样我们就不必每次需要时都检索它。

// 4

存储我们将在本章后面进行的象限计算中使用的两条线的斜率。

// 5

用于描述玩家射击方向的枚举。

开始()

添加Start()方法,我们将使用它来初始化和设置在整个武器类中需要的变量。

void Start()
{

// 1
    animator = GetComponent<Animator>();

// 2
    isFiring = false;

// 3
    localCamera = Camera.main;
}

// 1

通过获取对 Animator 组件的引用进行优化,这样我们就不必在每次需要时都检索它。

// 2

首先将isFiring变量设置为false

// 3

获取并保存对本地摄像机的引用,这样我们就不必在每次需要时都检索它。

更新更新()

Update()方法做两个小的修改,如下所示:

void Update()
{
    if (Input.GetMouseButtonDown(0))
    {

// 1
        isFiring = true;
        FireAmmo();
    }

// 2
    UpdateState();
}

// 1

当鼠标左键被按下并抬起时,将isFiring变量设置为true。这个变量将在UpdateState()方法中被检查。

// 2

UpdateState()方法将更新每一帧的动画状态,不管用户是否按下了鼠标按钮。我们将很快编写这个方法。

确定方向

为了确定播放哪个动画剪辑,我们需要确定用户相对于播放器单击的方向。如果用户点击播放器的西边,只是为了播放向东发射弹弓的动画,这看起来不会很好。

为了确定用户点击的方向,我们将屏幕分成四个象限:北、南、东和西。我们应该认为所有的用户点击都是相对于玩家的,所以这四个象限都以玩家为中心,如图 8-35 所示。

img/464283_1_En_8_Fig35_HTML.jpg

图 8-35

基于当前玩家位置的四个象限

我们可以检查用户点击了哪个象限,以确定玩家发射弹弓的方向,以及要播放的正确动画剪辑。

根据玩家的位置将屏幕划分为象限是有意义的,但是我们实际上如何通过编程来确定用户点击了哪个象限呢?

回想一下你高中数学时代的斜率截距形式:

  • y = mx + b,

其中:

m =斜率(可以是正斜率,也可以是负斜率)

xy 是一个点的坐标

b =是y-截距,或直线与y-轴相交的点。

这种形式允许我们沿着一条线找到任何一点。正如我们在图 8-35 中看到的,我们通过将屏幕分成象限创建了两条线。如果我们想象用户在屏幕上的任何一点点击鼠标,我们可以想象另一组两条线从点击的点出现。

诀窍是:我们可以根据鼠标点击的正斜线是在玩家的正斜线之上还是之下来确定用户点击了哪个象限。同样,我们检查鼠标点击的负斜线是高于还是低于玩家的负斜线。

看一下图 8-36 以获得可视化的帮助。记住向上倾斜的线有一个正斜率,向下倾斜的线有一个负斜率。

img/464283_1_En_8_Fig36_HTML.jpg

图 8-36

点击西象限

两条斜率相等的直线意味着它们彼此平行。

为了检查一条线是否在另一条线之上,斜率相等,我们简单地比较它们的y-截距。如图 8-36 所示,如果鼠标点击线的y-截距在负玩家线之下,但在正玩家线之上,则用户点击了西象限。

关于这种方法,有一些事情你应该内化。如果玩家站在屏幕的正中央,每条线都会从一个角走到另一个角。当玩家在场景中移动时,线条也跟着移动。象限的可见大小发生了变化,但是划分屏幕的两条线的斜率保持不变。每条线的斜率保持不变,因为屏幕尺寸永远不会改变——只有她的位置会改变。

当我们编写代码时,我们将重新排列斜率截距形式 y = mx + b ,以便更容易比较 y 截距。因为我们在比较 y 截距,我们需要求解 b 。所以重排后的形式是:b = y–MX。

让我们继续写代码。

斜率法

给定一条直线上的两点,计算直线斜率的标准方程为:(y2–y1)/(x2–x1)= m,其中 m =斜率。

写出来,那就是:第二个y-坐标减去第一个y-坐标,除以第二个x-坐标减去第一个x-坐标。

将以下方法添加到武器类以计算直线的斜率:

float GetSlope(Vector2 pointOne, Vector2 pointTwo)
{
    return (pointTwo.y - pointOne.y) / (pointTwo.x - pointOne.x);
}

计算斜率

让我们使用GetSlope()方法。将以下内容添加到Start()方法中。

// 1
Vector2 lowerLeft = localCamera.ScreenToWorldPoint(new Vector2(0, 0));
Vector2 upperRight = localCamera.ScreenToWorldPoint(new Vector2(Screen.width, Screen.height));
Vector2 upperLeft = localCamera.ScreenToWorldPoint(new Vector2(0, Screen.height));
Vector2 lowerRight = localCamera.ScreenToWorldPoint(new Vector2(Screen.width, 0));

// 2
positiveSlope = GetSlope(lowerLeft, upperRight);
negativeSlope = GetSlope(upperLeft, lowerRight);

// 1

创建四个向量来代表屏幕的四个角。Unity 屏幕坐标(不同于我们用来创建清单和健康栏的 GUI 坐标)从左下角的(0,0)开始。

我们也将每个点在分配之前从屏幕坐标转换到世界坐标。我们这样做是因为我们将要计算的斜率将与玩家相关。玩家在世界空间中移动,世界空间使用世界坐标。正如我们在本章前面所描述的,世界空间是真实的游戏世界,在大小方面没有限制。

// 2

使用GetSlope()方法得到每条线的斜率。一条线从左下角到右上角,另一条线从左上角到右下角。因为屏幕尺寸将保持不变,所以斜率也将保持不变。我们计算斜率并将结果保存到一个变量中,这样我们就不必在每次需要时重新计算。

比较y-截距

HigherThanPositiveSlopeLine()方法中,我们计算鼠标点击是否高于穿过玩家的正斜线。将以下内容添加到武器类。

bool HigherThanPositiveSlopeLine(Vector2 inputPosition)
{

// 1
    Vector2 playerPosition = gameObject.transform.position;

// 2
    Vector2 mousePosition = localCamera.ScreenToWorldPoint(inputPosition);

// 3
    float yIntercept = playerPosition.y - (positiveSlope * playerPosition.x);

// 4
    float inputIntercept = mousePosition.y - (positiveSlope * mousePosition.x);

// 5
    return inputIntercept > yIntercept;
}

// 1

为清晰起见,保存对当前transform.position的引用。这个脚本附加到玩家对象,所以这将是玩家的位置。

// 2

将鼠标位置inputPosition转换到世界空间并保存一个参考。

// 3

稍微重排一下 y = mx + b 来求解 b 。这将很容易比较每条线的y-截距。这条线上的形式是:b = y–MX。

// 4

使用重新排列的形式:b = y–MX,找到inputPosition(鼠标)创建的正斜线的y-截距。

// 5

比较鼠标点击的y-截距和穿过玩家的线的y-截距,如果鼠标点击更高则返回。

HigherThanNegativeSlopeLine()

除了我们将鼠标点击的 y 截距与穿过播放器的负斜线进行比较之外,HigherThanNegativeSlopeLine()方法与HigherThanPositiveSlopeLine()相同。将以下内容添加到武器类。

bool HigherThanNegativeSlopeLine(Vector2 inputPosition)
{
    Vector2 playerPosition = gameObject.transform.position;
    Vector2 mousePosition = localCamera.ScreenToWorldPoint(inputPosition);

    float yIntercept = playerPosition.y - (negativeSlope * playerPosition.x);

    float inputIntercept = mousePosition.y - (negativeSlope * mousePosition.x);

    return inputIntercept > yIntercept;
}

我们将放弃对HigherThanNegativeSlopeLine()方法的解释,因为它与前面的方法几乎相同。

GetQuadrant()方法

GetQuadrant()方法负责确定用户点击了四个象限中的哪一个,并返回一个Quadrant。它利用了我们之前编写的HigherThanPositiveSlopeLine()和 HigherThanNegativeSlopeLine()方法。

// 1
Quadrant GetQuadrant()
{

// 2
    Vector2 mousePosition = Input.mousePosition;
    Vector2 playerPosition = transform.position;

// 3
    bool higherThanPositiveSlopeLine = HigherThanPositiveSlopeLine(Input.mousePosition);

    bool higherThanNegativeSlopeLine = HigherThanNegativeSlopeLine(Input.mousePosition);

// 4
    if (!higherThanPositiveSlopeLine && higherThanNegativeSlopeLine)
    {

// 5
        return Quadrant.East;
    }
    else if (!higherThanPositiveSlopeLine && !higherThanNegativeSlopeLine)
    {
        return Quadrant.South;
    }
    else if (higherThanPositiveSlopeLine && !higherThanNegativeSlopeLine)
    {
        return Quadrant.West;
    }
    else
    {
        return Quadrant.North;

    }
}

// 1

返回描述用户点击位置的象限。

// 2

抓取用户点击位置和当前玩家位置的引用。

// 3

检查用户是否单击了正斜线和负斜线的上方(高于)。

 // 4

如果用户的点击不高于正斜线,但高于负斜线,则用户点击了东象限。如果这还不太有意义,请回头参考图 8-36 。

// 5

返回Quadrant.East枚举。

其余的 if 语句检查剩余的三个象限并返回它们各自的Quadrant值。

UpdateState()方法

UpdateState()方法检查玩家是否开火,检查用户点击了哪个象限,并更新 Animator 以便混合树可以显示正确的动画剪辑。

void UpdateState()
{

// 1
    if (isFiring)
    {

// 2
        Vector2 quadrantVector;

// 3
        Quadrant quadEnum = GetQuadrant();

// 4
        switch (quadEnum)
        {

// 5
            case Quadrant.East:
                quadrantVector = new Vector2(1.0f, 0.0f);
                break;
            case Quadrant.South:
                quadrantVector = new Vector2(0.0f, -1.0f);
                break;
            case Quadrant.West:
                quadrantVector = new Vector2(-1.0f, 1.0f);
                break;
            case Quadrant.North:
                quadrantVector = new Vector2(0.0f, 1.0f);
                break;
            default:
                quadrantVector = new Vector2(0.0f, 0.0f);
                break;
        }

// 6
        animator.SetBool("isFiring", true);

// 7
        animator.SetFloat("fireXDir", quadrantVector.x);
        animator.SetFloat("fireYDir", quadrantVector.y);

// 8
        isFiring = false;

    }
    else
    {

// 9
        animator.SetBool("isFiring", false);
    }
}

// 1

Update()方法中,我们检查用户是否点击了鼠标按钮。如果是,变量isFiring被设置为等于true

// 2

创建一个Vector2来保存我们将传递给混合树的值。

// 3

调用GetQuadrant()来确定用户点击了哪个象限,并将结果分配给quadEnum

// 4

打开象限(quadEnum)。

// 5

如果quadEnum是东,在新的Vector2中给quadrantVector赋值(1,0)。

// 6

将动画师内部的isFiring参数设置为true,这样它会过渡到火焰混合树。

// 7

将 animator 中的fireXDir和 fireYDir 变量设置为用户点击的象限的相应值。这些变量将被火焰混合树拾取。

// 8

isFiring设置回 false。动画将在停止之前一直播放,因为我们将过渡中的退出时间设置为 1。

// 9

如果isFiring为假,将动画器中的isFiring参数也设置为false

保存武器脚本并返回到 Unity 编辑器。

按下播放键,在场景周围的各个地方点击鼠标,发射弹弓。请注意玩家动画如何显示她向特定方向发射弹弓,然后返回空闲状态。

受损时的 flickr

当一个角色在电子游戏中被损坏时,有一个视觉效果来表示他们已经被损坏是很有帮助的。为了给我们的游戏增加一点光泽,让我们创建一个效果,将任何角色染成红色一会儿,也许是十分之一秒,以显示他们受伤了。这种闪烁效果会在几帧内发生,因此作为协程来实现是有意义的。

打开字符类,并在底部添加以下代码:

public virtual IEnumerator FlickerCharacter()
{

// 1
    GetComponent<SpriteRenderer>().color = Color.red;

// 2
    yield return new WaitForSeconds(0.1f);

// 3
    GetComponent<SpriteRenderer>().color = Color.white;
}

// 1

Color.red分配给 SpriteRenderer 组件会将 sprite 染成红色。

// 2

产出执行 0.1 秒。

// 3

默认情况下,SpriteRenderer 使用白色的淡色。将 SpriteRenderer 色调更改回默认颜色。

更新玩家和敌人的职业

打开玩家和敌人类,更新每个类中的DamageCharacter()方法如下。更新DamageCharacter()时,务必将StartCoroutine调用添加到while()循环的顶部。

public override IEnumerator DamageCharacter(int damage, float interval)
{
    while (true)
    {

// 1
        StartCoroutine(FlickerCharacter());

              //... Pre-existing code

// 1

启动FlickerCharacter()协程将字符暂时染成红色。

就这样!按下播放并向敌人发射弹弓。被击中时,它应该会短暂闪烁红色。如果一个敌人设法赶上玩家并伤害她,她也会闪烁红色。

为平台而建

在这一节,我们将学习如何编译你的游戏在 Unity 编辑器之外的几个平台上运行。

转到菜单栏中的文件➤构建设置。您应该会看到一个类似图 8-37 的屏幕。

img/464283_1_En_8_Fig37_HTML.jpg

图 8-37

构建设置屏幕

构建设置屏幕允许您选择目标平台,调整一些设置,选择要包含在构建中的场景,然后创建构建。如果您的游戏包含多个场景,请单击“添加开放场景”按钮来添加它们。

我们将选择 Mac OS X,但如果您在 PC 上工作,应该已经选择了它。

按下“构建”按钮。选择二进制文件的名称和保存位置,然后按 save 按钮。Unity 将创建构建,并在成功时通知您。

要玩游戏,请转到您保存游戏的位置,双击图标。当出现图 8-38 所示的屏幕时,确保为您使用的计算机选择正确的分辨率。如果你使用错误的分辨率,你的游戏可能会出现起伏。

img/464283_1_En_8_Fig38_HTML.jpg

图 8-38

为您的计算机选择分辨率

该屏幕还允许用户选择图形质量,如果他们有一台旧机器,这很重要。

按下播放!按钮来玩你的游戏!

退出游戏

天下没有不散的宴席,在某个时候,用户会想要退出你的游戏。在这一节中,我们将学习如何构建允许用户按 Escape 键退出游戏的功能。

当在 Unity 编辑器中玩游戏时,这种游戏结束功能将不起作用——它只适用于当你在编辑器外运行游戏时。

打开 RPGGameManager 类并添加以下内容:

void Update()
{
    if (Input.GetKey("escape"))
    {
        Application.Quit();
    }
}

Update()方法将检查每一帧,看用户是否按了退出键。如果是这样,退出应用。

摘要

咻——这一章我们讲了很多。您已经使用协程构建了智能追逐行为,并且在这样做的过程中,为玩家构建了第一个真正的挑战。玩家现在可以死亡,需要能够保护自己,所以我们做了一个弹弓,可以向敌人发射弹药。slingshot 利用了一种广泛使用的优化技术,称为对象池。我们利用了一些高中水平的轨迹弧三角学。我们学习了混合树,以及如果我们想在未来添加额外的动画,它们如何帮助我们更好地组织我们的游戏架构和简化状态机。我们也知道了为 PC 或 Mac 开发游戏并在 Unity 之外运行它是多么简单。

你可能对如何改变和改进你的游戏有一些想法。伟大的事情是:你现在有这样做的技能!尝试、打破常规、修补脚本、阅读文档,并检查其他人的代码以从中学习。你能做什么的唯一限制是你愿意投入多少努力。

下一步是什么

你可能想知道接下来会发生什么——你如何提高你的游戏开发知识并开发出更好的游戏。一个很好的起点是参与游戏开发者社区。

团体

没有人天生是任何方面的专家。成为更好的开发人员的关键是向更有经验的开发人员学习。你永远不想成为房间里最好的开发者。如果你是,确保其他开发人员也很棒,这样你就可以向他们学习。

Meetup.com 是一个寻找每月游戏开发者聚会的好地方。Meetup 也有官方 Unity 用户组 meetup 的列表。可能你所在的城市有一个团结聚会,而你并不知道。世界各地都有官方的 Unity 用户组。如果你所在的城市或城镇没有当地的团结聚会,考虑开一个吧!

Discord 是一款专门为游戏玩家设计的语音和文本聊天应用。这也是一个虚拟会见开发者的好地方。不和谐社区可以回答问题,也可以与社区进行有益的互动。有时游戏开发者会创建他们自己的专用于他们游戏的 Discord 服务器,在那里他们收集反馈,收集 bug 报告,并分发早期版本。

如果不提及 Twitter,任何关于社区的讨论都是不负责任的。Twitter 有助于宣传和营销你的游戏,也有助于联系其他 Unity 开发者。

Reddit 维护了两个对游戏开发者有用的活动子 Reddit:/r/unity 2d/r/gamedev 。这些子 reddits 可以是一个很好的地方来发布你的工作演示和收集反馈,以及与其他热情的游戏开发者进行讨论。 /r/gamedev 子 reddit 也有自己的 Discord 服务器。

了解更多信息

Unity 在其网站 https://unity3d.com/learn/ 上托管了大量频繁更新的教育内容。内容从绝对初学者到高级都有,一定要去看看。

这个网站: https://80.lv ,有游戏开发者感兴趣的各种主题的文章。一些文章是 Unity 特有的,而另一些是更通用的技术。

YouTube 也有助于学习新技术,尽管内容质量可能差异很大。在 YouTube 上可以很容易地找到过去 Unity 会议的许多演讲。

哪里可以找到帮助

每个人都会在某个时候遇到一个无论如何都无法解决的问题。对于这种情况,有几个重要的资源需要了解。

Unity Answers ( https://answers.unity.com )是一个有用的资源,是为问答(Q & A)而不是扩展讨论而构建的。例如,一个问题的标题可能是:“调试这个运动脚本有问题。”

Unity 论坛( https://forum.unity.com )是 Unity 员工和其他游戏开发者经常光顾的活跃留言板。论坛旨在围绕主题进行讨论,而不是简单的问答互动。你会发现很多有用的“有什么技术可以优化它”的讨论,比你在 Unity 的回答中发现的更多。

最后, https://gamedev.stackexchange.com 是 Q & A 网站的栈交换网络的一部分。它不像 Unity 网站那样繁忙,但如果你遇到问题,绝对值得你花时间。

游戏堵塞

游戏堵塞是构建视频游戏的黑客马拉松。他们通常有一个时间限制,比如 48 小时,这意味着给参与者施加压力,让他们只关注游戏中必要的东西,同时鼓励他们的创造力。游戏堵塞需要所有类型的参与者:艺术家、程序员、游戏设计师、声音设计师和作家。有时候游戏卡顿会有一个特定的主题,通常会提前保密。

游戏堵塞可以是一种奇妙的方式来满足本地(或远程)游戏开发商,推动自己,扩大你的知识,并带走(希望)一个完成的游戏。全球游戏大赛( https://globalgamejam.org )是一年一度的全球游戏大赛,有世界各地的不同站点和数百名参与者。Ludum Dare ( https://ldjam.com )是一个每四个月举办一次的周末游戏。如果你想看并制作一些令人惊奇的游戏,这两个游戏都是很好的参与方式。另一个找到在线游戏堵塞的好地方是 itch.io/jams.

新闻和文章

Gamasutra.com 是游戏新闻、工作和行业事件的旗手。另一个不错的网站是 indiegamesplus.com,有新闻、评论和对独立游戏开发者的采访。

游戏和资产

正如我们在第一章中提到的,Unity 资产商店包含数以千计的免费和付费游戏资产,以及脚本、纹理和着色器。关于资产商店,你应该注意的常见批评是,严格使用商店中的资产制作的游戏看起来“千篇一律”

Itch.io 是一个广为人知的发布独立游戏和资源的社区。你可以上传自己制作的游戏,免费玩其他独立游戏,或者通过购买其他开发者的游戏来支持他们。Itch.io 也是为你的游戏购买美术或声音资源的好地方。Gamejolt.com 类似于 itch.io,但完全专注于独立游戏,没有资产。

OpenGameArt.org 有大量用户上传的游戏作品,可以通过各种许可获得。

超越!

如果你已经和我在一起这么久了,那么你就有毅力通读一本几百页的编程书。这种坚韧将在游戏编程中很好地为你服务,因为尽管有大量的例子和书籍教授游戏编程的基础知识,但真正独特和有趣的游戏往往包含没有教程的元素。构建有趣的游戏可能非常困难,但很少有其他有创意的冒险是值得的。要想在游戏编程方面做得更好,最重要的是要记住继续做游戏!游戏开发就像任何其他学科一样——如果你坚持练习,总有一天你会回头看看你开始的地方,让自己大吃一惊。