- 在屏幕上放置一个带有轨迹的球体
- 基于玩家的输入控制球体的位置
- 控制速度和加速度
- 限制球体的位置并让它会被边缘反弹
这是移动控制系列的第一个教程. 它将介绍如何基于玩家的输入来滑动一个球体.
本教程使用的Unity版本是2019.2.9f1, 并且假定你已经掌握了C#与Unity基础系列教程中的所有内容
在平面上的球体
控制位置
很多与角色有关的游戏需要通过移动来达到某些目标. 玩家的任务就是控制和引导角色进行移动. 动作类游戏通过按键或摇杆来直接控制角色. 点击控制类游戏需要你指向一个目标位置并点击, 玩家将会自动的移动到目标位置. 编程类游戏需要你书写角色需要执行的命令代码. 等等.
本系列教程将致力于如何在一个3D游戏中控制角色. 我们会从简单内容开始, 围着一个简单的矩形平面内滑动球体. 掌握了这种简单内容是在以后做出复杂功能的前提.
场景设置 Setting the Scene
新建一个默认的3D项目. 我通常会使用线性颜色空间(linear color space), 你可以通过菜单位置Edit / Project Settings / Player / Other Settings进行设置(此处设置可更改可不更改, 不影响后续教程, 但是影响场景内物体的颜色显示效果)
线性颜色空间设置
默认的SampleScene场景包含了一个摄像机和一个平行光源, 不需要更改它们. 在Hierarchy窗口点击右键菜单 : 3D > Plane来新建一个平面物体Plane代表地面, position设置为(0, 0, 0).
在Hierarchy窗口点击右键菜单 :3D > Sphere来新建一个球体Sphere, position设置为(0, 0.5, 0), 默认的球体半径为0.5, 所以这样设置使得它看起来像是刚好位于Plane之上
当前Hierarchy窗口中的内容
我们要限制球体只在2D平面内发生移动, 所以需要将摄像机设置在屏幕正上方向下卡, 以在Game窗口获得最好的观察视角. 接着设置摄像机的Projection属性为Orthographic, 取代默认的Perspective, 这样我们就可以观察到不被透视效果影响的2D移动了.
按照图中来设置摄像机的Position, Rotation和Projection
现在还有一件事可能会影响我们的视线, 那就是球体的阴影. 你可以选中Directional Light, 设置它的Shadow Type为No Shadows, 如下图 :
在Project窗口中新建三个材质, 如下图, 命名为Ground Material, Sphere Material和Trail Material. 设置Shpere Material的颜色为黑色, Ground Material的颜色为灰色, Trail Material的颜色为暗红色. 接着我们再创建一个脚本, 叫做MovingShpere用来实现我们的移动代码逻辑
现在Project 窗口中的资源情况
MovingSphere脚本只保留以下内容 :
using UnityEngine;
public class MovingSphere : MonoBehaviour {
}
Game窗口内容
为球体添加Unity自带的Trail Renderer组件, 并把我们创建的Moving Sphere也拖拽到球体上
现在球体Inspector的样子
将Trail Material材质拖拽到Trail Renderer组件的Material属性下方的空栏内. 它不需要产生阴影, 不过这没什么关系, 因为我们已经将光源的阴影选项关闭了. 接着, 设置曲线的Width为0.1, 这样就可以通过该组件产生较细的线了
设置Trail Renderer的Width 和 Materials
尽管我们还没有编写任何移动代码, 但是可以在Scene窗口直接拖动改变球体的位置来观察Trail Renderer组件产生的轨迹效果
移动轨迹
读取玩家输入
我们需要根据读取到的玩家输入来控制球体进行移动. 我们可以在MovingSphere脚本的Update方法中书写对应代码. 玩家的输入是2D的, 所以我们可以将其存储在一个Vector2类型的变量中. 在一开始我们设置该变量的X和Y都是0, 然后我们使用这个向量设置球体在XZ平面内的位置. 因为该变量的Y代表的是Z轴坐标, 所以球体的Y轴坐标保持为0(此处原文有误, 应该设置Y为0.5, 而不是0, 下方示例代码我直接也修改了这个错误) :
using UnityEngine;
public class MovingSphere : MonoBehaviour {
void Update () {
Vector2 playerInput;
playerInput.x = 0f;
playerInput.y = 0f;
transform.localPosition = new Vector3(playerInput.x, 0.5f, playerInput.y);
}
}
以上代码还不能读取到玩家输入, 读取玩家输入的最简单办法是使用Input.GetAxis(“输入坐标轴名称”)方法. Unity默认已经定义了水平输入坐标轴Horizontal和垂直输入坐标轴Vertical, 你可以在项目设置的Input分类下查看它们. 我们将使用水平输入的值代表X, 垂直输入的值代表Y :
//playerInput.x = 0f;
playerInput.x = Input.GetAxis("Horizontal");
//playerInput.y = 0f;
playerInput.y = Input.GetAxis("Vertical");
默认设置将以上两个输入轴的控制分别与小键盘方向或是WASD按键对应, 保存代码, 运行游戏查看效果
这两个输入轴都有代表手柄左摇杆的备用定义设置, 使用手表摇杆可以获得更平滑的输入, 不过本教程后面提到的内容都将使用键盘完成.
为什么不使用Input System包?
你可以选择使用它, 但是其实现跟上述方法是一样的. 我们需要的就是获得两个输入轴上的值.
(建议不要去研究这个包了, 跟着教程写代码即可, 避免无法与教程内容对应而出现问题)
归一化输入向量
无输入时输入轴返回0, 有输入时候返回值在-1到1之间. 要注意的是, 键盘每个按键的输入是独立的, 可以单独输入左右或是上下, 所以键盘控制下球体的移动范围是一个矩形; 而对于手柄摇杆, 其输入值是一个整体, 始终会同时输入左右和上下两个值, 并保障返回值始终代表一个单位为1的圆内的一点, 所以手柄输入下的移动范围更像是一个圆形.
这意味着手柄摇杆输入时, 不论在哪个方向上. 输入向量的最大长度始终为1, 所以摇杆在任何方向上的移动速度都一样快. 对于键盘则不是这样, 单独按键的最大输入向量长度是1, 而两个按键的输入向量最大程度则是√2, 这意味着在对角线方向上球体移动的速度更快.
这个√2是由勾股定理得来的. 输入轴的值定义了一个直角三角形的两条直角边的长度, 而组合向量就是这个直角三角形的斜边长度, 因此, 两个按键下输入向量的最大长度就是
我们可以通过让向量除以向量长度来保障获取到的不会超过1的值. 其结果将始终代表单位长度为1的向量, 简称单位长度向量, 不过如果它的长度为0, 那么结果将没有意义. 我们将获取单位长度向量的过程称之为向量的归一化(normalize). 我们不需要手动进行上述计算, 而是可以使用向量类型的Normalize方法来获取单位长度向量, 该方法在处理长度为0的向量时也将返回0, 让我们使用它修改我们的代码 :
playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");
//将playerInput归一化, 会得到方向不变但是向量长度为1的结果.
playerInput.Normalize();
约束输入
将输入向量归一化之后, 球体的移动轨迹也被约束在了一个圆形范围内, 在输入向量为0时会停在原点. 在原点和圆的边之间出现的轨迹线代表了球体从圆心瞬间(1帧, 也就是一次Update的执行)跳跃到到圆的边上的过程或反过程(归一化之, 球只能要么在圆心, 要么在圆的边上, 位置不会再出现在圆内部)
像是这样的要么为0要么为1的输入没有太大问题, 不过我们也可以让圆内的所有位置都可以被球体移动到. 这就需要我们只在向量长度超过1的时候才执行归一化, 对于长度小于1的输入向量不做处理. 一个方便的办法就是使用Vector2.ClampMagnitude方法来代替Normalize方法, 该方法接受两个参数, 如果第一个向量参数的长度小于第二个参数, 返回这个向量本身, 否则将返回该向量归一化后结果, 我们的代码修改如下 :
//playerInput.Normalize();
playerInput = Vector2.ClampMagnitude(playerInput, 1f);
控制速度
至此, 我们已经可以直接使用输入来设置球体的位置. 这意味着输入向量i改变时, 球体的位置p也立即发生改变. 因此. 这样不能算是正确的移动控制, 因为球体的位置在没输入的时候就会恢复原样. 我们实际需要让球体能被输入移动到下一个位置p1, 而p1则是通过对移动前的位置p0加上一个向量d得到的, 也就是
相对运动
通过使用d=i替代p=i, 我们让输入与位置之间不再直接关联. 这也移除了之前的圆形移动范围, 现在球体的移动位置将相对于它自身移动前的位置而不是圆心位置. 所以球体的位置就可以被描述为一个无限的队列, 而p0指的就是球体的初始位置, 让我们将代码修改如下 :
//新增变量displacement
Vector3 displacement = new Vector3(playerInput.x, 0f, playerInput.y);
//transform.localPosition = new Vector3(playerInput.x, 0.5f, playerInput.y);
transform.localPosition += displacement;
相对移动, 现在球体可以任意的在平面内移动
移动速度
我们的小球现在可以自由奔跑了, 不过它跑的太快了, 有点难以驾驭. 这是因为我们在每一次Update方法执行时都将输入向量加到了它的位置值中. 帧率越高, 小球的速度也就越快. 我们需要可以不被帧率影响的控制小球的移动速度.
这就需要我们能够知道上一帧到这一帧的时间t是多长, 我们可以使用Time,deltaTIme来得到这个时间, 所以我们的移动距离d就应该等于时间t乘以输入i, 在这里我们假设这个t是个常数.
移动距离在Unity中的单位被假定为1米. 我们对其乘了时间系数, 单位是秒. 既然这样i乘t等于移动距离d, 那么i的单位就应该是 米/秒, 所以输入i其实就代表移动速度, 所以 v=i 并且 d=vt, 修改我们的代码如下 :
//Vector3 displacement = new Vector3(playerInput.x, 0f, playerInput.y);
Vector3 velocity = new Vector3(playerInput.x, 0f, playerInput.y);
Vector3 displacement = velocity * Time.deltaTime;
不被帧率影响的移动速度
速度变化
我们输入向量的最大长度是1, 这代表的就是1米/秒的移动速度, 等于3.6公里/小时, 或是2.24英里/小时, 这不算很快.
我们可以通过缩放输入向量来提高我们的最大移动速度, 所发系数代表了最大速度的数值, 也就是没有方向概念的单纯数字. 添加一个带有SerializeField特性的名为maxSpeed的字段, 并使用Range特性将其Inspector中的调整范围设置在0到100之间 :
public class MovingSphere : MonoBehaviour
{
[SerializeField, Range(0f, 100f)]
float maxSpeed = 10f;
SerializeField关键字是什么作用?
它告诉Unity序列化(serialize)这个字段, 这意味着该字段的值会被Unity编辑保存, 并可以显示在Inspector中进行调整. 使用publc也能让字段达到同样效果.
用serializeField可以将不是public的字段也显示在Inspector中, 同时不需要将这些字段开放给其他类的代码使用.
接着, 将输入向量与速度系数相乘, 得到最终的速度 :
Vector3 velocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
MaxSpeed设置为10
加速度
因为我们直接控制球体的速度值, 所以它的速度都可以瞬间根据输入而改变. 现实中物体的运动速度是不会立即改变的, 而是需要一定的时间, 就好像是我们改变球体的速度一样, 需要一个过程. 速度变化的速率被称之为加速度a, 我们可以写出速度与加速的公式为 , 其中v0的值是0. 减速就是单纯的相反方向的加速度, 所以不需要对其进行特殊处理. 让我们看看如果使用输入来控制加速度而不是直接控制速度会发生什么. 这需要我们持续跟踪当前速度, 所以我们需要将当前速度保存到一个字段中 :
public class MovingSphere : MonoBehaviour
{
Vector3 velocity;
输入向量现在需要在Update方法中用来定义加速度, 我们依然先让它还乘以maxSpeed, 用来暂时表示最大加速的计算. 然后将加速度的计算结果加到之前用来计算距离的移动速度值中 :
//新增acceleration变量代表加速度
Vector3 acceleration = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
//Vector3 velocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
velocity += acceleration * Time.deltaTime;
平滑的速度变化
期望速度
控制加速度而不是速度之后, 产生了更加平滑的移动效果, 但是这也降低了我们对于球体的控制性. 多数游戏中, 需要更直接的控制移动速度, 但是控制加速度也确实让移动效果更加平滑了.
加速度改变速度, 速度又改变位置(横轴是时间, 纵轴是对应的变化)
我们可以将两种方法进行组合, 使用加速度控制速度, 直到速度达到所需的值为止. 我们可以通过设置最大加速度来调整球体对于输入的响应度. 添加一个序列化的字段maxAcceleration :
public class MovingSphere : MonoBehaviour
{
[SerializeField, Range(0f, 100f)]
float maxAcceleration = 10f;
在Update方法中, 我们使用输入向量来定义一个变量desiredVelocity, 代表我们要达到的目标移动速度, 并且不再按照之前的方法去设置移动速度的值 :
//velocity += acceleration * Time.deltaTime;
Vector3 desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
取而代之的是, 我们首先通过最大加速度与时间t相乘得到速度变化的最大值. 这个值代表本次Update我们可以改变多少当前运动速度 :
Vector3 desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
//在desiredVelocity下面加入一行代码来获取本次Update的运动速度改变值
float maxSpeedChange = maxAcceleration * Time.deltaTime;
接着, 我们首先考虑x轴上的速度, 如果x轴上的当前速度小于目标移动速度的x轴速度, 则将x轴上的当前速度加上计算出的速度变化值 :
float maxSpeedChange = maxAcceleration * Time.deltaTime;
//处理x轴上的移动速度
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);
}
同样, 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);
}
我们也可以通过更简便的方法Mathf.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);
Max Speed和Max Acceleration都设置为10
现在我们可以调整最大加速度来获得具有良好控制性的平滑移动效果.
约束位置
除了控制角色的运动速度之外, 另一个重要的部分就是限制角色可以移动到的位置范围. 我们的简单场景包含了一个平面代表地面, 让我们将球体的位置约束在平面范围之内.
待在正方形内
与其使用平面本, 不如将可移动位置区域作为球体的一个序列化字段. 我们能使用Rect结构类型的值来代表这个区域. 通过调用它的构造方法来为它设置与平面区域匹配的默认值, 设置构造方法的前两个参数为-5, 后两个参数为10. 这两对参数分别代表它的左下角位置和宽高 :
public class MovingSphere : MonoBehaviour
{
[SerializeField]
Rect allowedArea = new Rect(-5f, -5f, 10f, 10f);
我们在将新计算的位置值赋值给球体之前, 对其值进行约束处理, 进而约束小球可以移动到的位置. 所以需要使用一个变量来存储计算出来的新位置 :
//transform.localPosition += displacement;
Vector3 newPosition = transform.localPosition + displacement;
transform.localPosition = newPosition;
我们可以对约束范围调用Contains方法来判断一个点是否处于范围之内. 如果新计算出的位置不在该范围内, 则需要保持小球位置不发生变化, 即新的位置就等于当前位置 :
Vector3 newPosition = transform.localPosition + displacement;
//使用Contains方法判断一个点是否在Rect结构变量代表的范围之内
if (!allowedArea.Contains(newPosition)) {
newPosition = transform.localPosition;
}
transform.localPosition = newPosition;
当我们能传递一个Vector3参数到Contains方法时, 该方法检查的是X和Y坐标是否处于范围内, 所以对于我们的位置来说, Y位置没有意义, 要检查的是X和Z坐标, 所以需要将if代码进一步做如下修改 :
//if (!allowedArea.Contains(newPosition)) {
if (!allowedArea.Contains(new Vector2(newPosition.x, newPosition.z))) {
小球现在可以在平面边缘停止了
球体现在不在可以离开平面区域内, 而是会在即将超出平面范围时停在那里. 不过实际效果有一些问题, 因为运动会在某些帧被忽略, 不需要烦恼, 因为我们很快就会修复这个问题. 在修复该问题之前, 注意球体现在可以移动到平面的边缘之上. 这是因为我们限制它的位置时没有考虑到它的半径. 将所有球体都限制在屏幕范围之内看起来会更好. 我们可以修改代码, 将球体的半径考虑到限制规则内, 但是实际上我们这只需要在Inspector中缩小限制范围即可, 在Inspector中将Allowed Area的修改为(X-4.5, Y-4.5, W9, H9)如下图所示 :
当球体碰触到平面边缘时就会停止, 不会在处于边缘之上
精确定位
我们现在可以通过 只限制会超出边缘的运动方向上的坐标 来取代 完全不进行移动, 修复一下存在的移动问题. 我们可以调用Mathf.Clamp, 对一个值进行最大值和最小值的选择. 使用xMin和xMax属性得到限制区域的X坐标范围, 并通过yMin和yMax得到Z坐标的限制范围 :
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);
}
消除速度
球体现在出停靠在边缘. 在接触到边缘后我们可以沿着边缘滑动, 但是离开边缘有时却有一段时间的延迟. 这是因为球体在接触边缘后速度并未降至0, 而是依然向着边缘方向. 我们需要在球体接触边缘而停止后, 能够马上控制它离开边缘, 而不是先对原来的速度进行一段时间的减速再离开.
如果我们的球体是一个真正的球, 而区域边缘是墙, 那么如果球滚到了墙边就应该停下. 目前我们的球体也是这样表现的. 但是试想一下, 如果墙突然消失, 球不应该重新获得它滚到墙边时的速度. 因为它的动量已经消失了, 它的能量已经在碰撞过程中被消耗了. 所以我们需要在球体接触区域边缘时让速度消失. 但是球体依然应该可以沿着边缘滑动, 因此只有指向边缘方向的速度需要消失
为了将正确的速度设置为0, 我们需要检查我们是否在两个维度方向上都超出了边界. 由于需要根据不同情况设置球体接触边缘年后的速度, 所以此时我们应该自己检查并设置球体的位置, 取代之前的Mathf.Clamp和Contains方法做的事情 :
//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;
}
反弹
速度不会总是在碰撞时消失. 如果我们的球体是一个完美的弹性球, 它应该会在碰撞后发生反弹, 用下面的代码试试 :
if (newPosition.x < allowedArea.xMin) {
newPosition.x = allowedArea.xMin;
//velocity.x = 0;
velocity.x = -velocity.x;
}
else if (newPosition.x > allowedArea.xMax) {
newPosition.x = allowedArea.xMax;
//velocity.x = 0;
velocity.x = -velocity.x;
}
if (newPosition.z < allowedArea.yMin) {
newPosition.z = allowedArea.yMin;
//velocity.z = 0;
velocity.z = -velocity.z;
}
else if (newPosition.z > allowedArea.yMax) {
newPosition.z = allowedArea.yMax;
//velocity.z = 0;
velocity.z = -velocity.z;
}
球体在反弹后的速度回有点变慢, 这是因为反弹后的运动速度与玩家的输入不再匹配, 所以如果需要反弹时候球体依然可以保持较高的运动速度, 需要在反弹发生时, 同时调整你的输入匹配反弹后的运动方向
弹性削减
我们不需要反弹发生后让球体保持与反弹前一样的速度. 有些物体的弹性非常强. 我们可以将弹性设置为一个可以配置的字段, 起名为bounciness, 设置其默认和为0.5, 并能通过Inspector在0到1之间调节. 这允许我们让球体进行完美的反弹或是完全不反弹, 或是介于两者之间 :
public class MovingSphere : MonoBehaviour
{
[SerializeField, Range(0f, 1f)]
float bounciness = 0.5f;
然后我们修改代码, 让反弹之后的速度乘以该反弹系数 :
if (newPosition.x < allowedArea.xMin) {
newPosition.x = allowedArea.xMin;
//velocity.x = -velocity.x;
velocity.x = -velocity.x * bounciness;
}
else if (newPosition.x > allowedArea.xMax) {
newPosition.x = allowedArea.xMax;
//velocity.x = -velocity.x;
velocity.x = -velocity.x * bounciness;
}
if (newPosition.z < allowedArea.yMin) {
newPosition.z = allowedArea.yMin;
//velocity.z = -velocity.z;
velocity.z = -velocity.z * bounciness;
}
else if (newPosition.z > allowedArea.yMax) {
newPosition.z = allowedArea.yMax;
//velocity.z = -velocity.z;
velocity.z = -velocity.z * bounciness;
}
Bounciness设置为0.5
这并不能代表更为复杂的真实物理规则, 但是类似的效果对于多数游戏来说已经足够良好. 同时, 我们的运动也不是非常精确. 我们的计算结果只有当在一帧内移动结束时到达边缘才是正确的, 否则我们的球体可能并不会恰好贴在边缘. 不过好在对于本例, 我们不需要非常精确的模拟出令人信服的弹性球体的感觉
下一个教程是物理系统