“饥荒”风格的俯视角2.5D实现

引言

本文内容包括俯视角 2D 人物移动控制、2.5D 风格的实现一机使用协程实现相机绕玩家旋转效果。

通常游戏按照镜头角度可以分为 2D 游戏和 3D 游戏,但还有一种独树一帜的游戏风格,将二维与三维的特点结合起来,在 2D 图像上展现出了 3D 的效果,我们称之为 2.5D,也就是伪 3D。有很多非常受欢迎的游戏都采用了这种风格,而这种风格也将它们从同类的其他游戏中凸显出来,例如《饥荒》和《八方旅人》。

今天我们就来一起使用 Unity 制作出类似《饥荒》 的俯视角 2.5D 风格。

场景搭建

首先我们来快速制作一个 2D 的俯视角场景和人物控制。首先从资源商店里导入我们需要的游戏素材:

这个素材是免费的,你可以很容易的从资源商店找到。

接着更改素材的尺寸,然后把需要的素材的轴心设置为底部,保证 sprite 在旋转后位于地面上层:

在场景中创建一个瓦片地图,然后打开平铺调色板创建瓦片资源:

导入完成后就可以开始绘制地图了:

为了让场景物体和玩家有碰撞,我们给场景物体添加碰撞器。

新建一个空物体 FaceObject,将所有需要朝向摄像机的物体都作为这个物体的子物体,便于管理:

角色

然后就是角色。我们首先创建一个 sprite,指定它的默认精灵并重命名为 Player

Player 设置标签 Player,方便之后查找。

我们需要 player 移动,与场景中的物体碰撞和播放动画,所以我们给 player 添加上刚体、碰撞体和动画控制器:

角色动画

选中 player 并按下 ctrl+6 快捷键打开动画窗口,将序列帧素材拖到窗口中创建新动画:

然后我们打开动画器,可以看到动画已经导入了。这里我们用 blendtree 快速实现动画的过渡,右键 create stat,选择 blendtree 重命名为 walk

双击这个节点打开新混合树。目前这个混合树只接受一个输入参数,我们需要两个输入参数来表示二维平面上的移动。选择类型为 2D simple directional,这样我们就有两个可接受的参数了:

所以我们创建两个 float 类型的参数,分别取名为 InputXInputY

然后在混合树中就可以选择接受的两个参数了。我们选择好刚创建的两个参数,点击加号添加要混合的动画:

设置完之后我们可以拖动屏幕中心的红点来模拟输入,混合树会自动的对我们输入的值进行判断播放哪个动画。然后我们还需要静止的画面,回到上一层同样创建一个混合树用来播放静止动画,命名为 idle,再创建一个 bool 变量 isMoving 来切换两组动画:

设置好两个混合树的切换条件:

设置 idle 混合树的混合动画:

角色控制

接下来我们来编写一个简单的程序控制 Player 的移动。新建 C# 脚本起名为 PlayerMovement,然后打开它,首先设置好需要的变量:

public float speed;
new private Rigidbody2D rigidbody;
private Animator animator;
private float inputX, inputY;

其中 speed 控制移动速度,rigidbodyanimator 获取刚体和控制体,inputXinputY 获取键盘上的方向输入。在 Start() 函数中通过变量获取 Player 相应组件方便后续使用:

void Start()
{
rigidbody = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
}

然后在 Update() 函数中实时获取到玩家的输入信息,使用 GetAxisRaw() 获取玩家的输入,这个函数会根据参数字符串获取到不同的玩家输入:

inputX = Input.GetAxisRaw("Horizontal");
inputY = Input.GetAxisRaw("Vertical");

例如我这里输入 Horizontal 也就是水平的英文单词,这个参数会自动获取我的左右和 AD 这两组按键并返回一个 -1 到 1 的值,可以用这种方法非常快速的获取到玩家输入。按键对应的字符串可在 Edit - Project Setting 中的 Input Manager 中找到:

我们现在就可以找到垂直输入的参数是 Vertical,它对应的按键是上下和 WS。回到脚本中获取到 Y 轴的输入,用一个 Vector2 向量接受输入并进行 normalized 标准化:

Vector2 input = (transform.right * inputX + transform.up * inputY).normalized;

然后使用这个变量乘以速度赋值给刚体的速度:

rigidbody.velocity = input * speed;

那么为什么需要将向量标准化呢?我们先取消标准化然后运行游戏,人物现在可以在场景中自由移动了,但当同时按下 X 轴和 Y 轴的按键时角色的移动会比正常情况下更快,这是因为 X 轴的速度和 Y 轴的速度叠加了。事实上我们只需要速度的方向,因此这个向量的长度必须是 1,而标准化后的向量方向不会变,而大小被限制成了 1。现在加上标准化再次运行,可以看到速度没有异常了。

接下来来控制动画的播放。当存在输入的时候,也就是 inputXinputY 不为零的时候,修改动画控制器为 true,否则为 false

运行游戏,可以看到移动时的动画在正常播放,但不会停在原来的方向。我们查看动画器可以发现,一旦我们停止输入,动画器的参数就会归零,而混合树则会默认播放第一个动画。

我们的解决办法是传给动画器有效的输入,新增两个变量 stopXstopY 记录输入不为零的部分,当存在输入的时候才更新这两个值,然后将这两个值传给动画器:

if (input != Vector2.zero)
{
animator.SetBool("isMoving", true);
stopX = inputX;
stopY = inputY;
}
else
{
animator.SetBool("isMoving", false);
}
animator.SetFloat("InputX", stopX);
animator.SetFloat("InputY", stopY);
}

再次运行,可以看到我们的动画已经完美运行了。

视角

接下来就是重头部分了,我们将把这个场景从 2D 扩展到 2.5D。事实上 Unity 的 2D 是基于 3D 的,我们只需要点击这个 2D 按钮取消 2D 模式,Unity 就会回到 3D视图:

透视投影

在这个视图下我们可以调整相机的角度俯视地图,不过首先要修改相机的投影模式,修改为透视。这个模式通常用于 3D,有近大远小的感觉:

你可以随意更改相机的角度,不过我推荐 45 度,这是最常用的视角,能最大程度的平衡玩家和视野场景:

然后编写简单脚本来控制场景中的物体都朝向摄像机。新建 C# 脚本,起名为 FacingCamera,将脚本添加到场景副物体上,进入脚本。

首先我们需要一个数组变量来获取这个物体的所有子物体,在 Start() 方法中循环遍历所有子物体并存储下来:

Transform[] childs;
void Start()
{
childs = new Transform[transform.childCount];
for (int i = 0; i < transform.childCount; i++)
{
childs[i] = transform.GetChild(i);
}
}

Update() 方法中遍历子物体,并让子物体的旋转值设置成和 camera 一样:

void Update()
{
for (int i = 0; i < childs.Length; i++)
{
childs[i].rotation = Camera.main.transform.rotation;
}
}

这样场景中的所有 sprite 就会始终朝向摄像机了,再在 PlayerMovement 脚本中加上简单的相机跟随 Player 位置代码:

......
+private Vector3 offset;

void Start()
{
+ offset = Camera.main.transform.position - transform.position;
......
}

void Update()
{
......
+ Camera.main.transform.position += offset;
}

最后在项目设置 - 图形设置透明度排序模式为透视,这将让 sprite 根据距离相机远近渲染先后顺序:

运行场景,这样一个还不错的 2.5D 场景就制作完成了。

旋转视角

但在最后我们要再实现一个《饥荒》中的小功能:按下 qe 键旋转摄像机,这让我们可以更全面的观察整个场景。

《饥荒》中的摄像机是围绕着玩家旋转的,每次旋转 45 度,那么如何让摄像机围绕玩家旋转呢?我们可以使用 RotateAround() 方法,但经过我的测试,这种方法并不好用。然后我发现,其实没必要让摄像机主动转起来,只需要让摄像机作为子物体,旋转父物体就会让摄像机被动地绕父物体旋转。所以我们先让摄像机作为玩家的子物体,再旋转玩家,就可以看到绕玩家旋转的效果。

但我们没办法就这样简单的旋转玩家控制摄像机,还记得刚刚的 FacingCamera 脚本吗?Player 的旋转角度实际上是受摄像机的旋转角度控制的,所以我们新建一个空物体,起名为 CameraPosition并把摄像机作为子物体,这样我们就可以通过旋转或移动这个物体控制摄像机的位置和旋转。

为这个物体新建脚本 RotatingCamera,进入脚本。首先我们要删掉之前在 PlayerMovement 脚本中的相机跟随脚本。

我们需要一个公有变量控制一次旋转需要的时间 rotateTime,然后需要一个变量 player 来获取 Player 的位置,还需要一个 bool 类型的变量 isRotating 来防止多次旋转冲突:

public float rotateTime = 0.2f;
private Transform player;
private bool isRotating = false;

Start() 函数中获取到 PlayerTransform,在 Update() 函数中让摄像机的位置一直跟随玩家,然后编写控制旋转的函数 Rotate() 和进行旋转的协程函数:

void Start()
{
player = GameObject.FindGameObjectWithTag("Player").transform;
}

void Update()
{
transform.position = player.position;
Rotate();
}

这里简单解释一下协程函数的概念。在普通的函数中,无论内部有多少代码,进行多少次循环,都将在 1 帧内执行完,着就导致我们无法在普通函数中使用循环来做一个持续进行的效果。如果不幸遇到了死循环,还会导致 Unity 失去响应,这时候就需要用到协程函数,这种函数内部在执行时可以先暂停执行,等到固定的时间后继续执行,加上循环后给人的感觉就像是一个可以设置更新频率,自动停止的 Update() 函数,可以很方便的制作程序性动画或一个循序渐进的效果。

协程函数没有返回值但可以有参数,在声明的时候必须使用 IEnumerator 关键字。我们创建一个协程,取名为 RotateAround(),传入两个参数,分别为旋转的角度和旋转的时间。

然后首先计算出需要旋转多少次,我这里是用 FixUpdate() 的帧率更新的,1 秒执行 60次。接着根据次数和总角度计算每次需要旋转多少度。

接下来开始循环计算出的次数,每次旋转一个较小的度数,然后使用 yield return new WaitForFixedUpdate 暂停执行,等到下一帧时继续执行下个循环:

IEnumerator RotateAround(float angel, float time)
{
float number = 60 * time;
float nextAngel = angel / number;
isRotating = true;

for (int i = 0; i < number; i++)
{
transform.Rotate(new Vector3(0, 0, nextAngel));
yield return new WaitForFixedUpdate();
}

isRotating = false;
}

开启协程需要使用 StartCoroutine() 函数,传入 -45 角度和时间,按照同样的方法写出 e 的旋转:

void Rotate()
{
if (Input.GetKeyDown(KeyCode.Q) && !isRotating)
{
StartCoroutine(RotateAround(-45, rotateTime));
}
if (Input.GetKeyDown(KeyCode.E) && !isRotating)
{
StartCoroutine(RotateAround(45, rotateTime));
}
}

回到 Unity 开始运行,可以看到我们的旋转效果已经成功运行了:

源码

PlayerMovement

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

public class PlayerMovement : MonoBehaviour
{
public float speed;
new private Rigidbody2D rigidbody;
private Animator animator;
private float inputX, inputY;
private float stopX, stopY;

void Start()
{
rigidbody = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
}

void Update()
{
inputX = Input.GetAxisRaw("Horizontal");
inputY = Input.GetAxisRaw("Vertical");
Vector2 input = (transform.right * inputX + transform.up * inputY).normalized;
rigidbody.velocity = input * speed;

if (input != Vector2.zero)
{
animator.SetBool("isMoving", true);
stopX = inputX;
stopY = inputY;
}
else
{
animator.SetBool("isMoving", false);
}
animator.SetFloat("InputX", stopX);
animator.SetFloat("InputY", stopY);

}
}

FacingCamera

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

public class FacingCamera : MonoBehaviour
{
Transform[] childs;
void Start()
{
childs = new Transform[transform.childCount];
for (int i = 0; i < transform.childCount; i++)
{
childs[i] = transform.GetChild(i);
}
}

void Update()
{
for (int i = 0; i < childs.Length; i++)
{
childs[i].rotation = Camera.main.transform.rotation;
}
}
}

RotatingCamera

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

public class RotatingCamera : MonoBehaviour
{
public float rotateTime = 0.2f;
private Transform player;
private bool isRotating = false;
void Start()
{
player = GameObject.FindGameObjectWithTag("Player").transform;
}

void Update()
{
transform.position = player.position;

Rotate();
}

void Rotate()
{
if (Input.GetKeyDown(KeyCode.Q) && !isRotating)
{
StartCoroutine(RotateAround(-45, rotateTime));
}
if (Input.GetKeyDown(KeyCode.E) && !isRotating)
{
StartCoroutine(RotateAround(45, rotateTime));
}
}

IEnumerator RotateAround(float angel, float time)
{
float number = 60 * time;
float nextAngel = angel / number;
isRotating = true;

for (int i = 0; i < number; i++)
{
transform.Rotate(new Vector3(0, 0, nextAngel));
yield return new WaitForFixedUpdate();
}

isRotating = false;
}
}

项目地址