Catlike Coding翻译——Movement 01移动球体(玩家控制)

原文链接

在平面上放置一个带有拖尾的球体
根据玩家输入移动球体
控制速度和加速度
限制球体位置以及让他从边缘弹开

这是关于控制角色移动的系列教程的第一部分。具体来说,我们将根据玩家输入来移动一个球体。

这个教程使用Unity 2019.2.9f1制作。在学习此教程之前,我们假定你已经完成Basic教程部分。

黏在平面上的球体

1 控制位置

许多游戏都是关于一个角色必须四处走动以完成某些目标。玩家的任务是引领角色。动作游戏让你通过按键或转动操纵杆来控制角色。点按游戏让你指示目标位置,角色会自动移动到那里。编程游戏让你写指令,让角色执行。如此等等。
在这个系列教程里,我们会专注于如何控制一个在3D动作游戏里的角色。我们会先从简单的部分开始:在一个小的、平面的矩形上移动球体。一旦我们掌握了这一点,我们在将来会让它变得更复杂。

1.1 设置场景

我们从一个新的默认3D场景开始。目前我们不需要Pakage Manager里的任何东西,当然你可以根据自己的需要使用渲染管线。
我经常使用linear色彩空间,你可以在Edit/Project Settings/Player/Other Settings里进行配置。

Linear色彩空间

默认的SampleScene场景有一个照相机和平行光,我们保留这两个。创建一个Plane代表地面,加上一个Sphere,都放置于原点。默认的球体半径为0.5,所以设置它的Y坐标为0.5,让它看起来像是在地表。
场景层级

我们限制自己在地面上进行2维移动,所以让我们把相机置于地面正上方往下看,以在游戏视图中获得一个良好的视野。同时设置相机的Projection模式为Orthigraphic。这样就避免了透视,让我们能观察到不失真的2D运动。

往下方看的正交相机

最后一个扰乱我们视线的是球体的阴影。依据你的Unity版本,设置灯光Shdow Type属性为None或No Shadows。
灯光不会投射阴影

依据你的喜好为地面和球体创建材质。我把球设为黑色,把地面设为浅灰色。我们也会使用拖尾来可视化运动轨迹,所以也要创建一个拖尾的材质。我将为它使用一个无反射红色材质。最后,我们需要一个MovingSphere脚本来使移动生效。
项目资源

脚本可以从一个空的MonoBehaviour类开始
using UnityEngine;

public class MovingSphere : MonoBehaviour { }
游戏视图

把TrairRenderer组件和我们的MovingSphere组件都加到小球上。其他保持不变。
球体上的组件

把我们之前创建的Trail材质分配给TrailRenderer组件的材质数组中第一个也是唯一一个元素。它不用选中投射阴影,虽然这不是必要的(我们已经禁用了灯光的阴影投射)。除此之外,将Width从1.0减少为一个更加合理的值,比如0.1,以产生一条细线。

拖尾渲染组件

虽然我们还没有写任何移动代码,我们可以预览一下它的样子,进入Play模式,然后在Scene窗口拖拽球体即可。
移动产生的拖尾

1.2 读取玩家输入

为了移动球体,我们必须读取玩家输入的指令。我们会在MovingSphere里的Update方法里写这些代码。玩家输入是2D的,所以我们可以将它存在一个Vector2变量里。首先我们会将它的X、Y分量都设为0,然后用这些值来定位球体在XZ平面的位置。因此,输入的Y分量变成了位置的Z分量。Y的位置保持为零。

using UnityEngine;

public class MovingSphere : MonoBehaviour {

	void Update () {
		Vector2 playerInput;
		playerInput.x = 0f;
		playerInput.y = 0f;
		transform.localPosition = new Vector3(playerInput.x, 0f, playerInput.y);
	}
}

获取玩家方向输入最简单的方法是调用Input.GetAxis方法,并传入轴(Axis)的名称。Unity有定义在默认情况下的横向(Horizontal)纵向(Vertical)输入轴,你可以在Project Settings的Input模块查看它们。我们把横轴的值赋给X,纵轴的值赋给Y。

playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");

默认设置将这些轴链接到箭头键和WASD键。输入值同样被调整过了,所以这些的键的行为有点像手柄摇杆。你可以根据自己的喜好调整这些值,但是我保留了默认设置。


在这里插入图片描述

使用j箭头键或WASD键

为什么不使用输入系统插件?

你可以这么做,但是原理是一样的。我们所要做的只是获取两个轴的值。另外,在撰写本篇教程时,该插件还处于预览阶段,所以还没有正式发布和支持。

1.3 归一化输入向量

当坐标轴处于静止状态时返回零,在其极值处返回- 1或1。当我们使用输入来设定球体位置时,他被约束在一个正方形内。至少,键盘输入是这样的,因为键是相互独立的。然而在摇杆系统中,两个维度是相关的,我们通常将输入限制在从原点开始,任意方向最大值为1的距离内,也即是在一个圆内。
手柄输入的优点是,无论方向如何,输入向量的最大长度始终为1。所以移动能在所有方向一样快。但按键不是这样,单个按键的最大值是1,但两个键都按下时,最大值是√2,也就是说对角线移动是最快的。
根据勾股定理,键的最大值是√2。输入轴的值定义了直角三角形两条边的长度,他们的组合向量是斜边。因此,输入向量的模为 x 2 + y 2 \sqrt{x^2+y^2} x2+y2
我们可以通过将输入向量除以它的模来确保它的长度不超过1。这个结果总会是单位向量的长度,除非它的初始长度是零,这种情况下,结果是没有定义的。这个过程称为归一化向量。为了达成这点,我们可以调用向量的Normalize方法,那么向量将缩放自身,如果结果未定义,则成为零向量。

playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");
playerInput.Normalize();

在这里插入图片描述

归一化键盘输入

1.4 约束输入向量

始终归一化输入向量也会始终限制其位置在圆上,除非输入是无偏向的,这样我们会停在原点。在一个单独的帧中产生了原点和圆之间的线,这意味着小球会瞬间在圆和圆心上跳来跳去。
这样极端(1或0)的输入或许是可取的,但我们也要使圆内的所有位置都生效。我们将只在输入向量的大小超过1时才调整它。一个方便的做法是替换Normalize方法为静态方法Vector2.ClampMagnitude,并传入最大值1作为参数。结果是一个向量,要么和原输入值相同,要么缩小到所提供的最大值。

//playerInput.Normalize();
playerInput = Vector2.ClampMagnitude(playerInput, 1f);

在这里插入图片描述

约束键盘输入

2 控制加速度

到目前为止,我们所做的是直接根据输入来设置球体位置。这意味着当输入向量 i i i变化时,球体位置 p p p也会立刻随之变化。因此 p = i p=i p=i。这不是正确的运动方式,而是瞬间移动。一个更加自然的方式是把位移向量 d d d和原来的位置 p 0 p_0 p0相加,得到下一个位置 p 1 p_1 p1,即 p 1 = p 0 + d p_1=p_0+d p1=p0+d

2.1 相对运动

在使用 d = i d=i d=i替换了 p = i p=i p=i之后,我们使得输入和位置之间的影响不那么直接。这次更新移除了对位置的约束,因为它现在是相对于自身的而非原点。位置现在被一个无限迭代序列描述: p n p_n pn+1 = p n + d =p_n+d =pn+d,其中 p 0 p_0 p0表示起始位置。

Vector3 displacement = new Vector3(playerInput.x, 0f, playerInput.y);
//transform.localPosition = new Vector3(playerInput.x, 0f, playerInput.y);
transform.localPosition += displacement;
相对运动

2.2 速度

我们的小球确实可以移动到任何地方,但它跑得太快了,很难控制。这是每帧都添加输入向量的结果。帧率越高,它就跑得越快。为了得到一致的结果,我们不想让帧率影响输入。如果我们输入恒定,那么位移也应该是恒定的,不管帧率会如何波动。
为了达到我们的目的,一帧代表了这样一段时间:前一帧和当前帧之间时间 t t t变化的量,可以通过Time.deltaTime来访问它。这样我们的位移就是 d = i t d=it d=it,之前我们我们错误地假设了 t t t是一个常量。
位移是以Unity单位来衡量的,Unity假设一单位为一米。但是我们要把输入乘以时间间隔来表述每秒的位移。为了达到米的效果,输入必须以米每秒来衡量,这样输入向量即为速度: v = i , d = v t v=i,d=vt v=i,d=vt

		Vector3 velocity = new Vector3(playerInput.x, 0f, playerInput.y);
		//Vector3 displacement = new Vector3(playerInput.x, 0f, playerInput.y);
		Vector3 displacement = velocity * Time.deltaTime;
		transform.localPosition += displacement;
独立于帧率的速度控制

2.3 速率

我们最大输入向量的长度为1,表示了一个一米每秒的速度。等于每小时3.6千米,约等于每小时2.24英里。那并不是很快。
我们可以通过缩放输入向量来增加最大速度。缩放因子代表了最大速率,一种没有方向的速度。添加一个maxFiled字段,默认值是10,为它加上SerializedFiled属性,然后给它一个Range属性,就1-100吧。

[SerializeField, Range(0f, 100f)]
float maxSpeed = 10f;

SerializedField 是干什么用的?

它告诉Unity去序列化一个字段,这意味着字段会被保存并暴露在Unity编辑器中,让它能在属性栏中被更改。我们当然也能使字段公开(Public),但是通过这种方式,字段保持不受MovingSphere类以外的代码影响。

将输入向量和最大速度相乘,得到所需的速度。

Vector3 velocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;

最大速率设置为10

## 2.4 加速度 因为我们直接操纵速度,所以它可以瞬间改变。只有输入系统所应用的过滤方法(filtering)在某种程度上减缓了变化。然而在现实中速度不可能立马改变,就像改变位置一样,它需要一点费一点力和时间。速度的变化率称为加速度$a$,也就引出了$v_n$*~+1~*$=v_n+at$,其中$v_0$是零向量。减速只是一个与当前速度相反的加速度,因此不需要特殊处理。 让我们看看如果我们用输入向量来控制加速度,而不是直接控制速度会发生什么。这需要我们持续追踪当前速度,所以把它存在一个字段里。
Vector3 velocity;

目前输入向量在Update方法里定义了加速度,但是让我现在还是保持让它乘以maxSpeed,暂时把它重新定义为最大加速度。然后再计算位移之前,把它计入速度。

平滑的速度变化

2.5 期望速度

控制加速度,而不是直接控制速度,这样会产生更平滑的运动,但是这同样了减弱了我们对球的控制。这就像是开车对比走路。在大多数游戏里直接控制速度是由必要的,所以让我们回到那个实现方式。但是无论怎样,对加速度的应用确实产生了更平滑的动作。

加速度改变速度,速度转而改变位置

我们可以将这两种实现方法结合起来,直接控制目标速度,并对实际速度施加加速度,直到它匹配期望速度。然后我们可以通过调整球的最大加速度来调整它的灵敏度。为此要添加一个序列化字段。

[SerializeField, Range(0f, 100f)]
float maxAcceleration = 10f;

Update中,我们现在使用输入向量来定义一个期望速度,不再用旧的方法来调整速度。

Vector3 desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
//velocity += acceleration * Time.deltaTime;

相反,我们先通过把最大加速和 t t t相乘,得到最大速度变化。这就是这帧速度改变的幅度。

Vector3 desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
float maxSpeedChange = maxAcceleration * Time.deltaTime;

让我们首先考虑速度的X分量。如果它比期望的小,那就加上最大速度变化(即maxSpeedChange)。

float maxSpeedChange = maxAcceleration * Time.deltaTime;
if (velocity.x < desiredVelocity.x) {
	velocity.x += maxSpeedChange;
}

这可能导致速度过快,我们可以取增加值和期望值的最小值来预防这点。这里我们可以使用Mathf.Min方法。

if (velocity.x < desiredVelocity.x) {
	//velocity.x += maxSpeedChange;
	velocity.x = Mathf.Min(velocity.x + maxSpeedChange, desiredVelocity.x);
}

或许也会可能出现速度大于期望值的情况。在这种情况下,我们减去最大变化量,然后通过Mathf.Max取最大变化量和期望值之间的最大值。

if (velocity.x < desiredVelocity.x) {
	velocity.x = Mathf.Min(velocity.x + maxSpeedChange, desiredVelocity.x);
}
else if (velocity.x > desiredVelocity.x) {
	velocity.x = Mathf.Max(velocity.x - maxSpeedChange, desiredVelocity.x);
}

我们也可以通过方便的Marhf.MoveTowards方法来实现它,传入当前值、期望值和最大允许变化值。分别对X和Z分量进行处理。

float maxSpeedChange = maxAcceleration * Time.deltaTime;
		//if (velocity.x < desiredVelocity.x) {
		//	velocity.x = Mathf.Min(velocity.x + maxSpeedChange, desiredVelocity.x);
		//}
		//else if (velocity.x > desiredVelocity.x) {
		//	velocity.x = Mathf.Max(velocity.x - maxSpeedChange, desiredVelocity.x);
		//}
		velocity.x = Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange);
		velocity.z = Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange);

速率和加速度都设置为10

现在,我们可以调整最大加速度,来达到平滑运动和灵敏度的理想平衡。


3 约束位置

除了控制小球速度,游戏另外一个大块是限制小球移动范围。我们那个简单的场景包含了一块代表地面的平面。让我们把球保持在平面上。

3.1 待在方形里

我们直接把允许的区域做为小球的一个可序列化字段,而非使用平面本身。我们可以使用Rect结构体。给它一个与平面匹配的默认值:调用它的构造方法,前两个参数传-5,后两个参数传10。这些参数定义了它的左下角和大小。

[SerializeField]
Rect allowedArea = new Rect(-5f, -5f, 10f, 10f);

在将新位置赋值给transform.localPosition之前,我们要先约束它的位置。所以先把位置存到一个变量里。

//transform.localPosition += displacement;
Vector3 newPosition = transform.localPosition + displacement;
transform.localPosition = newPosition;

我们可以调用允许区域(Rect)的Contains方法,来检查一个点是否在它里面或在它边缘。如果不是的话,把新位置设为当前位置,即在这帧忽略移动。

Vector3 newPosition = transform.localPosition + displacement;
if (!allowedArea.Contains(newPosition)) {
	newPosition = transform.localPosition;
}
transform.localPosition = newPosition;

当我们传递一个Vector3到Contains时,它检查XY坐标,这在我们的例子中是不正确的。所以传递一个新的XZ坐标的Vector2


在平面边缘上停下

我们的小球再也无法逃出去了,当它尝试这么做时就会停下来。大现在结果是不稳定的,因为在它一些帧中忽略了移动,但我们将很快处理这个问题。在此之前,要注意的是球可以一直移动,直到它站在平面边缘上。这是因为我们限制了它的位置而没有考虑它的半径。如果整个球都留在允许范围内会看起来更好。我们可以更改代码来把半径纳入考虑,但另一种选择是直接缩小可行走区域的面积。这对我们的场景已经足够了。
将区域的角落往上提0.5,两个维度的大小各自减1。


在触碰到平面边缘时停下

3.2 精确定位

我们可以用限制新位置在可行走区域的方法替换忽略移动,以避免不平稳的运动。我们我可以调用Math.Clamp来实现这一点,传入要限制的值,和它允许的最大最小值。使用xMinxMax属性作为X的范围,yMinyMax属性作为Y的范围。

if (!allowedArea.Contains(new Vector2(newPosition.x, newPosition.z))) {
	//newPosition = transform.localPosition;
	newPosition.x = Mathf.Clamp(newPosition.x, allowedArea.xMin, allowedArea.xMax);
	newPosition.z = Mathf.Clamp(newPosition.z, allowedArea.yMin, allowedArea.yMax);
}
黏在边缘上

3.3 消除速度

小球现在表现得像是黏在边缘上一样。在抵达一条边缘时,我们会沿着边缘滑动,需要过一会儿才能从边缘脱离。这是因为小球的速度仍旧指向边缘。我们需要远离边缘的加速度来改变(速度)方向,这需要一段时间,取决于最大加速度。
如果我们的球体是一个小球,区域边缘是一道墙,那小球碰到墙时应该停下来。现在确实是这样的。但是如果墙突然消失了,小球将不能恢复它原来的速度。动量消失了,碰撞对小球的动能产生了损害,小球的能量在这过程中被转移。所以我们要消去碰撞边缘时的速度。但只是这样还是有可能沿着边缘滑动的,因此,只有指向该边缘方向的速度分量应被消除。
要把合适的速度分量设为0,我们必须检测在两个方向上、两个维度上是否都超出了界限。这时候,我们要自己限制小球的位置,同时也要做和Math.ClampContains相同的检测。

//if (!allowedArea.Contains(new Vector2(newPosition.x, newPosition.z))) {
			//newPosition.x = Mathf.Clamp(newPosition.x, allowedArea.xMin, allowedArea.xMax);
			//newPosition.z = Mathf.Clamp(newPosition.z, allowedArea.yMin, allowedArea.yMax);
		//}
		if (newPosition.x < allowedArea.xMin) {
			newPosition.x = allowedArea.xMin;
			velocity.x = 0f;
		}
		else if (newPosition.x > allowedArea.xMax) {
			newPosition.x = allowedArea.xMax;
			velocity.x = 0f;
		}
		if (newPosition.z < allowedArea.yMin) {
			newPosition.z = allowedArea.yMin;
			velocity.z = 0f;
		}
		else if (newPosition.z > allowedArea.yMax) {
			newPosition.z = allowedArea.yMax;
			velocity.z = 0f;
		}
不再黏在边缘上

3.4 反弹

速度并不总是在碰撞中消除。如果我们的球是一个完美弹跳的球,它会在相应的维度上改变方向。让我们试试。

if (newPosition.x < allowedArea.xMin) {
			newPosition.x = allowedArea.xMin;
			velocity.x = -velocity.x;
		}
		else if (newPosition.x > allowedArea.xMax) {
			newPosition.x = allowedArea.xMax;
			velocity.x = -velocity.x;
		}
		if (newPosition.z < allowedArea.yMin) {
			newPosition.z = allowedArea.yMin;
			velocity.z = -velocity.z;
		}
		else if (newPosition.z > allowedArea.yMax) {
			newPosition.z = allowedArea.yMax;
			velocity.z = -velocity.z;
		}
从边缘上弹开

现在球体保留了它的动量,它只是在撞到墙时改变了方向。它确实变慢了一点,因为在反弹后它的速度不再匹配期望速度。为了得到最好的弹跳结果,玩家需要立即调整他们的输入。

3.5 弹跳

我们不需要在小球反弹的时候保持全部的速度。有些东西比其他的更容易反弹。让我们通过添加一个bounciness字段来配置它,默认值设为0.5,范围设为0-1。这使得我们的球体可以完全弹起,或者完全不弹起,或者介于两者之间。

[SerializeField, Range(0f, 1f)]
float bounciness = 0.5f;

当碰撞边缘时,将弹性因素计入到新的速度值。

if (newPosition.x < allowedArea.xMin) {
			newPosition.x = allowedArea.xMin;
			velocity.x = -velocity.x * bounciness;
		}
		else if (newPosition.x > allowedArea.xMax) {
			newPosition.x = allowedArea.xMax;
			velocity.x = -velocity.x * bounciness;
		}
		if (newPosition.z < allowedArea.yMin) {
			newPosition.z = allowedArea.yMin;
			velocity.z = -velocity.z * bounciness;
		}
		else if (newPosition.z > allowedArea.yMax) {
			newPosition.z = allowedArea.yMax;
			velocity.z = -velocity.z * bounciness;
		}

弹力值设为0.5

这并不代表现实中的物理,现实中的物理要复杂得多。但是这已经看起来有点像了,已经满足了大部分游戏的的需求。而且,我们的移动也不是很精确。我们的计算只有在一个帧的运动结束时刚好到达边缘时才正确。然而现实很有可能不是这样的,这意味着我们应该立即把球移离边缘一点:首先计算剩余的时间,然后将其与相关维度中的新速度一起使用。然而,这可能会导致第二次弹跳,使事情变得更加复杂。幸运的是,为了呈现一个球体弹跳的错觉,我们并不需要那样的精度。

下一个教程是物理

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值