「Unity2D」使用Unity创建一个2D游戏系列-6

本文由泰然教程组成员 betterdenger 翻译,原文请参阅「Parallax scrolling」

视差滚动

现在我们已经创建了一个静态场景,还有玩家和敌人。但是依旧很无趣,所以我们该去增强我们的背景和场景了。

有种特效专业出没于各大2D游戏15年,这就是所谓的视差滚动(parallax scrolling)。

简单的说就是,给背景层不同的移动速度(也就是说越远的层移动速度越慢)。如果正确的实践,这种方式会带给玩家一种场景有深度的效果。这确实是一种很酷炫而且比较容易实现的效果。

现在我们开始在Unity里实现它吧。

原理: 定义我们游戏所需滚动

添加一个滚动轴需要思考一下我们如何更好的利用好我们的这个新技能。

最好想清楚了再写代码。:)

我们想移动什么?

我们做一下决定:

  1. 第一选择:玩家和摄像机移动。其余固定。
  2. 第二选择:玩家和摄像机相对是静止的。背景移动,所以就像个跑步机。

第一种选择是不需要用脑子的,如果你有一个Perspective摄像机的话。视差滚动很明显:背景元素有更大的深度。因此它在场景的更后面,也移动得更慢。

但是在Unity里对于一个标准的2D游戏而言,我们使用的是Orthographic摄像机。我们没有关于深度的渲染。

关于摄像机:还记得你的摄像机游戏对象的"Projection"属性么。它在我们的游戏里被设置为Orthographic
如果是Perspective,那就意味着这是一个传统的3D游戏摄像机,带有深度的管理。Orthographic的话渲染所有的东西都以相同深度。这点对于一个游戏中的GUI或者一个2D游戏很有用。

为了给我们的游戏添加视差滚动,我们混合使用这两种选择。我们会有两个滚动视图:

  1. 玩家跟随摄像机向前移动。
  2. 背景元素用不同的速度移动(除了相机移动)。

注意:你可能会问:"为啥我们不设置摄像机为玩家对象的子对象?"。确实是,在Unity中,如果你设置一个对象(摄像机或者其他的)为另一个游戏对象的孩子,这个对象将保持与其父对象的相对位置。所以如果摄像机是一个玩家的孩子, 摄像机会保持跟踪玩家。这也是一种解决方案,但是这对于游戏性来说有些不合适。

在一个shmup游戏里,摄像机会限制玩家的移动。如果摄像机跟随玩家水平或者垂直的移动,那玩家可以随意移动甚至飞出地图。在这里我们只想把玩家限制在我们设定的区域里。

我们建议始终要保持相机在一个2D游戏里独立。即便是一些平台游戏里,摄像机都没有和玩家挂钩: 摄像机根据某些设定条件跟踪玩家。你可以看看超级马里奥游戏里的摄像机,它就完成的不错。

敌军的出生

添加滚动后,我们的游戏出现了其他一些后果,特别是敌人。目前,敌人只是移动来移动去,然后在游戏开始的时候发点子弹。然后我们想让敌人通过出生的方式出现,并且出生前处于无敌状态。

我们怎样生成敌人啦?这完全取决于游戏。你可以定义一些事件,一旦它们被触发就生成敌人。关于出身敌人的位置等信息也需要预定义。

我们这样:我们先把Poulpies放到场景(直接从Prefab拖到场景就行了)。默认的是他们会保持静止,并且处于无敌状态,知道摄像机接触到并激活它们。

Camera usage

好消息是我们通过Unity编辑器来设置敌人。你没有看错,你啥都不需要做,你已经有了一个关卡编辑器了。

再说一次啊,我们只是选择这么做,你也可以选其他的。;)

注意:我们真心觉得使用Unity作为关卡编辑器是性价比非常高的。如果你有时间、有钱以及专用关卡设计师,那就必须要有个专用工具才能配得上的身份了。

平面

首先我们要定义我们所需的平面,指定它们是否循环滚动。一个循环滚动的背景会在关卡执行期间不停的循环滚动。比如,对于天空背景来说,这就很有用。

添加一个新的layer到场景里装背景元素。

我们这样设置:

| 层 | 循环滚动 | 位置 |
| ------ | ---------- | ------- |
| 天空底层背景 | 是 | (0, 0, 10) |
| 底层背景(第一个"飞台") | 否 | (0, 0, 9) |
| 中层背景(第二个"飞台") | 否 | (0, 0, 5) |
| 玩家和敌人所在的前景 | 否 | (0, 0, 0) |

Planes

我们可以想象有些层在玩家对象的前面。要让Z轴的值在[0, 10]区间范围之内,否则你就要调整摄像机。

程咬金,当心你的斧子:如果你在前景层添加了层,你要当心它的可见性。很多游戏没有使用这个技术的原因就是它会降低游戏的清晰度。

实践:深入代码

现在我们看看怎样在我们的游戏里实现视差滚动。

Unity在它的标准库里有一些视差滚动脚本(看看在Asset Store里的2D平台游戏demo)。你也可以用这些,但你肯定还是会对怎样构建这些脚本感兴趣的。

标准库:这是一种技巧,但是注意不要滥用它们。使用标准库会阻塞你的想法,同时让你游戏无法出众。它们让玩家一种觉得游戏有种Unity统一风格的味道。
就跟flash游戏一样。。。一种多胞胎克隆人的即视感。

简单滚动

我们先做这个个游戏简单部分:滚动背景图。

还记得我们之前用过的"MoveScript"么?基本上是一样的:速度和方向随着时间被改变。

创建一个"ScrollingScript"脚本:

    using UnityEngine;

    /// <summary>
    /// Parallax scrolling script that should be assigned to a layer
    /// </summary>
    public class ScrollingScript : MonoBehaviour
    {
        /// <summary>
        /// Scrolling speed
        /// </summary>
        public Vector2 speed = new Vector2(2, 2);

        /// <summary>
        /// Moving direction
        /// </summary>
        public Vector2 direction = new Vector2(-1, 0);

        /// <summary>
        /// Movement should be applied to camera
        /// </summary>
        public bool isLinkedToCamera = false;

        void Update()
        {
            // Movement
            Vector3 movement = new Vector3(
                speed.x * direction.x,
                speed.y * direction.y,
                0);

            movement *= Time.deltaTime;
            transform.Translate(movement);

            // Move the camera
            if (isLinkedToCamera)
            {
                Camera.main.transform.Translate(movement);
            }
        }
    }

把这个脚本通过这些值和游戏对象关联起吧:

| 层 | 速度 | 方向 | 连接摄像机 |
| ----- | ------ | ------- | ------------ |
| 0 - Background | (1, 1) | (-1, 0, 0) | 否 |
| 1 - Background elements | (1.5, 1.5) | (-1, 0, 0) | 否 |
| 2 - Middleground | (2.5, 2.5) | (-1, 0, 0) | 否 |
| 3 - Foreground | (1, 1) | (1, 0, 0) | 是 |

为了使场景更加真实,我们还需要添加一些元素到场景里:

  • 添加第三个背景图,之前我们已经添加了两个(请看教程关于添加和显示背景图章节)。
  • 在1 - Background层添加一些的"飞台"元素。
  • 在2 - Middleground层添加一切正常尺寸的"飞台"。
  • 在3 - Foreground层右边添加一些敌人。远离摄像机。

结果图:

Scrolling effect

还不错。但是我们看到敌人在摄像机范围之外的时候,甚至还没出生的时候就开始移动、射击。

此外,这些敌人和玩家对象擦肩而过后没有再回来(缩小场景视图,你可以看到左边场景里,这些"章鱼"还在移动)。

我们会马上修复这些问题。首先我们要管理这个无限滚动的天空背景。

背景视图无限滚动

为了得到一个无尽的背景,我们只需要观察一下在左侧无尽层的孩子。

当这个对象到达摄像机左侧边缘的时候,我们把它移动到该层的右边。无穷无尽。

Infinite scrolling theory

对于一个充满图片的层而言,摄像机要覆盖尽可能小的范围。天空的三部分,完全是任意的。

你要找到一个游戏资源消耗和游戏灵活性的平衡点。

在我们的例子中,我们得到层的所有孩子节点,检查它们的渲染器。

使用渲染器组件注意:这种方法对于不可见的对象没用(比如处理脚本的)。当然,你也几乎不会对这种对象去运用。

我们将使用一个方便的方法来检查对象的渲染器是否是对相机可见。它既不是类也不是脚本,是C#的extension。

Extension: C#语言可以扩展一个类,而无需看到类的源代码。
创建一个静态方法,第一个参数跟着需要拓展的类型和它的一个实例。现在你的这个类就自动在使用这个类的地方拥有了这个方法。

脚本"RendererExtensions"

创建一个新的C#文件命名为"RendererExtensions.cs", 写入以下代码:

    using UnityEngine;

    public static class RendererExtensions
    {
        public static bool IsVisibleFrom(this Renderer renderer, Camera camera)
        {
            Plane[] planes = GeometryUtility.CalculateFrustumPlanes(camera);
            return GeometryUtility.TestPlanesAABB(planes, renderer.bounds);
        }
    }

很简单,不是么?

命名空间: 你可能已经注意到了Unity当你从"Project"视图创建一个MonoBehaviour脚本的时候,外围并没有被namespace关键字包裹。目前,Unity确实有处理处理命名空间。
但是在本教程中,我们没有使用命名空间。当然在实际的项目中,你可能会考虑用到它们。或者是给自己的类添加比较特别的前缀,避免和其他的第三方库冲突(比如NGUI)。

我们将在无尽层最左边的对象上调用这个方法。

完整"ScrollingScript"

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

    /// <summary>
    /// Parallax scrolling script that should be assigned to a layer
    /// </summary>
    public class ScrollingScript : MonoBehaviour
    {
    /// <summary>
    /// Scrolling speed
    /// </summary>
    public Vector2 speed = new Vector2(10, 10);

    /// <summary>
    /// Moving direction
    /// </summary>
    public Vector2 direction = new Vector2(-1, 0);

    /// <summary>
    /// Movement should be applied to camera
    /// </summary>
    public bool isLinkedToCamera = false;

    /// <summary>
    /// 1 - Background is infinite
    /// </summary>
    public bool isLooping = false;

    /// <summary>
    /// 2 - List of children with a renderer.
    /// </summary>
    private List<Transform> backgroundPart;

    // 3 - Get all the children
    void Start()
    {
        // For infinite background only
        if (isLooping)
        {
        // Get all the children of the layer with a renderer
        backgroundPart = new List<Transform>();

        for (int i = 0; i < transform.childCount; i++)
        {
            Transform child = transform.GetChild(i);

            // Add only the visible children
            if (child.renderer != null)
            {
            backgroundPart.Add(child);
            }
        }

        // Sort by position.
        // Note: Get the children from left to right.
        // We would need to add a few conditions to handle
        // all the possible scrolling directions.
        backgroundPart = backgroundPart.OrderBy(
            t => t.position.x
        ).ToList();
        }
    }

    void Update()
    {
        // Movement
        Vector3 movement = new Vector3(
        speed.x * direction.x,
        speed.y * direction.y,
        0);

        movement *= Time.deltaTime;
        transform.Translate(movement);

        // Move the camera
        if (isLinkedToCamera)
        {
            Camera.main.transform.Translate(movement);
        }

        // 4 - Loop
        if (isLooping)
        {
            // Get the first object.
            // The list is ordered from left (x position) to right.
            Transform firstChild = backgroundPart.FirstOrDefault();

            if (firstChild != null)
            {
                // Check if the child is already (partly) before the camera.
                // We test the position first because the IsVisibleFrom
                // method is a bit heavier to execute.
                if (firstChild.position.x < Camera.main.transform.position.x)
                {
                // If the child is already on the left of the camera,
                // we test if it's completely outside and needs to be
                // recycled.
                    if (firstChild.renderer.IsVisibleFrom(Camera.main) == false)
                    {
                        // Get the last child position.
                        Transform lastChild = backgroundPart.LastOrDefault();
                        Vector3 lastPosition = lastChild.transform.position;
                        Vector3 lastSize = (lastChild.renderer.bounds.max - lastChild.renderer.bounds.min);

                        // Set the position of the recyled one to be AFTER
                        // the last child.
                        // Note: Only work for horizontal scrolling currently.
                        firstChild.position = new Vector3(lastPosition.x + lastSize.x, firstChild.position.y, firstChild.position.z);

                        // Set the recycled child to the last position
                        // of the backgroundPart list.
                        backgroundPart.Remove(firstChild);
                        backgroundPart.Add(firstChild);
                    }
                }
            }
        }
    }
    }

(以上注释中的数字对应以下说明)

解释

  1. 我们需要一个公有变量在"Inspector"来开关循环。
  2. 我们还需要一个私有变量存储层的子节点。
  3. Start()方法中, 我们把拥有渲染器的子节点保存到backgroundPart。多亏了LINQ, 我们通过它们的X坐标来排序,把越左边的放到数组的前面。
  4. Update()方法中, 如果isLooping被设置成true, 我们取回backgroundPart里第一个子节点。我们看它是不是完全在摄像机范围外,如果它在范围外,我们把它位置调整到最后一个(最右边的)子节点的后面。当然我们还要同步更新到backgroundPart列表里。

某种意义上说,backgroundPart代表了场景的情况。

记得要在0 - Background的"Inspector"面板里给"ScrollingScript"的"Is Looping"属性启用。否则它肯定没办法正常运行。

Infinite scrolling

(点击本图观看动画)

噢,yes!我们现在已经实现了视差滚动的功能了。

注意:为什么我们不使用OnBecameVisible()OnBecameInvisible()方法啦?

这些方法的基本思路都是在对象被渲染的时候执行一个代码段(反之亦然)。他们就像Start()或者Stop()方法(如果你需要,直接在MonoBehaviour添加方法,Unity会直接使用它)。

问题是这些方法在被Unity编辑器的场景视图渲染的时候也会被调用。这意味着我们在Unity编辑器里和最终编译平台得到的效果会不一样。这不仅危险而且很可笑。我们强烈推荐不要使用这些方法。

奖励: 增强现有脚本

让我们更新之前的脚本。

出生的敌人v2.0版

我们之前说过,敌人在摄像机可以看到之前是被禁用了的。

一旦他们离开屏幕也要被移除。

我们需要更新"EnemyScript"脚本, 实现以下功能:

  1. 禁止移动,碰撞和自动开火(初始化的时候)。
  2. 检查摄像机范围的渲染器。
  3. 激活自身。
  4. 当它们处于相机范围之外的时候摧毁游戏对象。

(数字对应代码中的注释中的数字)

    using UnityEngine;

    /// <summary>
    /// Enemy generic behavior
    /// </summary>
    public class EnemyScript : MonoBehaviour
    {
    private bool hasSpawn;
    private MoveScript moveScript;
    private WeaponScript[] weapons;

    void Awake()
    {
        // Retrieve the weapon only once
        weapons = GetComponentsInChildren<WeaponScript>();

        // Retrieve scripts to disable when not spawn
        moveScript = GetComponent<MoveScript>();
    }

    // 1 - Disable everything
    void Start()
    {
        hasSpawn = false;

        // Disable everything
        // -- collider
        collider2D.enabled = false;
        // -- Moving
        moveScript.enabled = false;
        // -- Shooting
        foreach (WeaponScript weapon in weapons)
        {
        weapon.enabled = false;
        }
    }

    void Update()
    {
        // 2 - Check if the enemy has spawned.
        if (hasSpawn == false)
        {
            if (renderer.IsVisibleFrom(Camera.main))
            {
                Spawn();
            }
        }
        else
        {
            // Auto-fire
            foreach (WeaponScript weapon in weapons)
            {
                if (weapon != null && weapon.enabled && weapon.CanAttack)
                {
                weapon.Attack(true);
                }
            }

            // 4 - Out of the camera ? Destroy the game object.
            if (renderer.IsVisibleFrom(Camera.main) == false)
            {
                Destroy(gameObject);
            }
        }
    }

    // 3 - Activate itself.
    private void Spawn()
    {
        hasSpawn = true;

        // Enable everything
        // -- Collider
        collider2D.enabled = true;
        // -- Moving
        moveScript.enabled = true;
        // -- Shooting
        foreach (WeaponScript weapon in weapons)
        {   
            weapon.enabled = true;
        }
    }
    }

开始游戏。。。额,好吧,这有个bug。

禁用"MoveScript"产生了一个副作用:玩家对象始终接触不到敌人,它们都跟随3 - Foreground层一起滚动了:

camera_moving_along_gif

记得我们添加到"ScrollingScript"这层是为了让摄像机跟随玩家对象移动吧。

所以这里有个简单的解决方案:从3 - Foreground移动"ScrollingScript"到玩家对象身上!

这个层里唯一需要移动的就是玩家对象,这个脚本也没有指定与特定对象。

按下"Play"按钮:起作用了!

  1. 敌人们在被摄像机可见之前都被禁用了。
  2. 当它们处于摄像机之外的时候都消失了。

Enemy spawn

(点击图片查看详情)

限制玩家在摄像机范围之类

你可能已经注意到了,玩家没有被限制在摄像机范围之内。如果在运行游戏中,一直按着左键,你就会看到玩家对象离开了摄像机范围了。

我们得解决这个问题。

打开"PlayerScript", 在"Update()"方法后面添加:

  void Update()
  {
    // ...

    // 6 - Make sure we are not outside the camera bounds
    var dist = (transform.position - Camera.main.transform.position).z;

    var leftBorder = Camera.main.ViewportToWorldPoint(
      new Vector3(0, 0, dist)
    ).x;

    var rightBorder = Camera.main.ViewportToWorldPoint(
      new Vector3(1, 0, dist)
    ).x;

    var topBorder = Camera.main.ViewportToWorldPoint(
      new Vector3(0, 0, dist)
    ).y;

    var bottomBorder = Camera.main.ViewportToWorldPoint(
      new Vector3(0, 1, dist)
    ).y;

    transform.position = new Vector3(
      Mathf.Clamp(transform.position.x, leftBorder, rightBorder),
      Mathf.Clamp(transform.position.y, topBorder, bottomBorder),
      transform.position.z
    );

    // End of the update method
  }

在上面的代码里,我们取得摄像机的边界,保证玩家的位置(sprite的中心点)在这个边界之内。

下一步

我们有了一个滚动的射击者了。

我们刚刚学会了如何给我们的游戏添加一个滚动机制,同时也为背景层增加了视差效果。然后当前的代码只保证了从右向左滚动。接下来我们学着增强它的功能,让它在所有方向滚动。

我们的游戏还需要一些改进来改变游戏性。比如:

  • 减少sprite尺寸
  • 调整速度
  • 增加更多的敌人
  • 让它更有趣

我们将在接下来的章节解决上面说到的问题来调整我们的游戏性。

同时,我们也会关注如何让我们的游戏更加酷炫。通过使用粒子效果。

标签: unity2d, 教程

?>