游戏中的角色运动问题
游戏中的角色运动问题
大部分类型的游戏中玩家都需要扮演一名主角,通过操纵主角来体验游戏,这就涉及到运动的问题。相信有不少Unity开发者都是从制作2D平台跳跃游戏入门的,从那时起到现在,也许有些关于运动的问题仍值得我们去思考,本文总结了一些个人学习时遇到的关于角色运动的问题及其学到的解决方法。
注意:这次的代码不太规范,只在一个脚本上实现所有内容,输入啥的也都混在一块 (就图一方便,因此仅供参考。
一、基础问题
1. 平面移动
角色最基础的运动方式,通常为了使玩家更好的与游戏环境进行物理交互,我们会用刚体控制玩家的运动。(当然,如果你的游戏想要独特的运动体验,那也可以通过Transform来运动,但这样就需要你处理好物理碰撞问题)如何控制刚体运动比较合适呢?
-
通过AddForce等刚体 自带的API
用这种方法的好处是 真实,这很符合现实的物理现象,我们通过力改变一个物体的加速度,再由加速度改变物体的速度,再在这种速度的作用下改变位移。但这种方式不直观,你很难直接知道在这个力的作用下,角色的具体速度能否符合预期,由或者在某个弹跳力的作用下,角色能否跳到预期的高度。
-
直接修改velocity的数值
刚体最终的运动状态是由velocity属性决定的,那直接修改velocity的值岂不更方便?的确,如果游戏所需的运动并不复杂,那么这种方式是可取的;但这样做的结果就是不真实,例如,角色只会做匀速运动,缺失起步时加速和停止时减速的效果。除非……
-
除非你能保证 对velocity的修改符合物理规律(推荐)
用方法1控制物体的运动过于复杂(要额外考虑物体质量啥的),而方法2又过于简单,那么我们可以综合一下:用比控制力更简单点的、但又是贴近真实的控制方法—— 控制加速度。
我们可以一起来试试,先创建一个名为TestMove的脚本:using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class TestMove : MonoBehaviour { [SerializeField] private float maxSpeed = 10f; //最大速度 [SerializeField] private float maxAcceleration = 10f; //最大加速度 private Rigidbody body; //物体挂载的刚体 private Vector2 playerInput; //记录玩家的输入(前后左右) private Vector3 velocity; //临时velocity,修改后会替换给刚体的velocity private void Awake() { body = GetComponent<Rigidbody>(); } private void Update() { playerInput.x = Input.GetAxis("Horizontal"); playerInput.y = Input.GetAxis("Vertical"); playerInput.Normalize(); // 将输入向量归一化,避免输入向量相加大于1 } private void FixedUpdate() { velocity = body.velocity; //待处理的移动逻辑 body.velocity = velocity; } }
我们会依据设定的「maxAcceleration(最大加速度)」来调整速度的变化快慢,但要注意,这个加速度预期是以每秒为单位的,所以要乘上Time.fixedUpdateTime。
接着,根据输入信号确定预期达到的速度;再借助 Mathf.MoveTowards 让当前速度按照加速度大小向预期速度调整(当前速度 > 预期速度时,会以maxAcceleration减小;反之,会以maxAccelerate增大)。private void FixedUpdate() { velocity = body.velocity; var acceleration = maxAcceleration * Time.fixedDeltaTime; targetSpeed = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed; velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed.x, acceleration); velocity.z = Mathf.MoveTowards(velocity.z, targetSpeed.z, acceleration); body.velocity = velocity; }
通过第3种方式控制,我们可以得到想要的、自然的移动效果,而且代码效果可控、不复杂。
2. 地面检测
是否“脚踏实地”,可是个重要问题。相当多的动作都需要玩家站在地面上才能完成,比如行走、跳跃。玩家现在站在地面上吗?还是只是靠在墙上呢?我所尝试过的方案如下:
-
足部检测线/盒
在我入门做2D平台跳跃游戏时,很多案例都是采用这种方式:在角色底部放出一条(或者多条)射线,用这段射线检测是否接触地面(或者更进一步,用检测到的物体的layer)。
在3D游戏中,可以将其替换为扁长方体放在角色底部来检测就跟穿鞋一样。这种方式简单,但却有不少小纰漏:
-
误判时机。当跳跃时,可能已经跳起一小段距离了,但射线仍可以接触到地面,从而误以为玩家仍在地面;落地时同理,可能会提早认为玩家落地了。
-
特殊情况。也许上面这种情况,你认为只要让检测的射线更短些就可以解决。那么难免会出现以下几种情况:检测范围太小,导致卡在凹陷中失去检测,从而误以为在空中;检测高度太小,而检测不到斜坡;检测范围太大,导致将接触的侧面墙壁也当作地面。
-
墙地分离。在2情况中的前两种都可以通过增加检测线(盒)大小来解决,你或许会想:只要将墙和地面的Layer区分开来不就可以了?这在某些游戏中确实可行,但很多关卡设计是允许玩家站在墙壁上方的。这样的考量合理性不足。
-
-
接触点的法线(推荐)
这种方法运用了一点数学知识,请允许我简单的介绍一番:-
首先,你要知道 「法线」,也就垂直于某个平面的线,我们就称这条线为那个平面的法线。无论一个平面如何扭曲,我们总能找到“立足”于那个平面某点上的法线。
-
其次,你要知道三角函数中的cos,我们假设法线的长度都为1,可以发现当地面越来越陡峭时,法线在竖直方向上的投影,也就是它的cos值会越来越小,直到地面完全垂直(变成墙壁)时,这个值会变成0。
-
在Unity中我们可以通过OnCollisionStay(OnCollisionEnter等也可以)函数,获取碰撞到的物体,并进一步获取碰撞点信息,其中就包括了法线。
-
如果只是地面检测,这就已经足够了,我们只需要在碰撞时,记录下是否有遇到碰撞面的法线大于0的情况就可以了,只要这个法线大于0,就意味着这个法线的平面一定不是墙。
当然,如果你觉得太陡的坡角色也不允许走的话,可以把0换成别的数字,比如0.4,这样的话,比cos值为0.4的角度(也就是2中第三个坡的情况)还大的坡,玩家就走不了。
我们在之前那个脚本的基础上,添加以下内容即可完成地面检测:[SerializeField] private bool IsOnGround; void OnCollisionStay2D(Collision2D collision) { CheckCollision(collision); } void OnCollisionEnter2D(Collision2D collision) { CheckCollision(collision); } void CheckCollision(Collision2D collision) { for(int i = 0; i < collision.contactCount; ++i) { var normal = collision.GetContact(i).normal; if(normal.y > 0) { IsOnGround = true; groundNormal += normal; } } } void FixedUpdate () { velocity = body.velocity; var acceleration = maxAcceleration * Time.fixedDeltaTime; targetSpeed = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed; velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed.x, acceleration); velocity.z = Mathf.MoveTowards(velocity.z, targetSpeed.z, acceleration); body.velocity = velocity; IsOnGround = false; //新增的内容:物理运动结束就置为false }
-
3. 斜坡运动
我们默认移动的方向是水平的,但在实际情况中,可能会有斜坡出现。虽然我们的角色也能在斜坡上行走,但若不加以调整,那在斜坡上行走的速度就会 与预期有所偏差 。
可以看看如下对比(用的是正交相机观察),这里我设置了两个最大速度为30、加速度为5的小球,下方的那个有进行调整,而上方的没进行调整,可以发现上方的速度稍慢与预期(有些情况下也会稍快,总之就是不符合预期):
造成这种现象的原因很明显,那就是移动的方向与地面不水平,导致了其分解。所以解决的办法便是:将移动的方向调整成与当前地面水平,就像下面这样:
在发生碰撞时先记录下能被地面的法线;
private Vector3 groundNormal;
void OnCollisionStay(Collision collision)
{
for(int i = 0; i < collision.contactCount; ++i)
{
var normal = collision.GetContact(i).normal;
if(normal.y > 0)
{
IsOnGround = true;
//因为接触点可能不止一个,要先累加所有的法线向量,使用时会归一化
groundNormal += normal;
}
}
}
再做一个能让向量投影在当前触碰地面的函数,得到与当前地面平行的向量(其实就是投影):
private Vector3 GetProjectOnPlane(Vector3 curVector)
{
//当前向量 - 法线向量 * (当前向量投影在法线的长度)
//就能得到当前向量投影在 产生法线的面(也就是地面)的向量
//注意:长度没进行处理,后续使用时根据需要进行归一化
return curVector - groundNormal * Vector3.Dot(curVector, groundNormal);
}
有了这个函数我们可以改改之前的行走逻辑,并把它写在Move函数中:
void FixedUpdate ()
{
velocity = body.velocity;
groundNormal.Normalize();//进行运动处理前,记得归一化当前地面的法线
Move();
groundNormal = Vector3.up;//默认地面法线是竖直向上(世界坐标y轴)的
body.velocity = velocity;
IsOnGround = false;
}
void Move()
{
var acceleration = maxAcceleration * Time.fixedDeltaTime;
targetSpeed = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
//以下为进一步修改的部分
var xAixs = GetProjectOnPlane(Vector3.right).normalized;//获取平行于地面运动的x轴
var zAixs = GetProjectOnPlane(Vector3.forward).normalized;//同理获得z轴
var curVelocityX = Vector3.Dot(velocity, xAixs);//通过投影,获取当前x轴上的运动速度
var curVelocityZ = Vector3.Dot(velocity, zAixs);//同理,获取当前z轴上的运动速度
// velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed.x, acceleration);
// velocity.z = Mathf.MoveTowards(velocity.z, targetSpeed.z, acceleration);
// body.velocity = velocity;
//计算这帧加速后的新x轴、z轴(左右、前后)的速度
var newVelocityX = Mathf.MoveTowards(curVelocityX, targetSpeed.x, acceleration);
var newVelovityZ = Mathf.MoveTowards(curVelocityZ, targetSpeed.z, acceleration);
//通过累加的方式,计算最终刚体的速度
velocity += xAixs * (newVelocityX - curVelocityX) + zAixs * (newVelovityZ - curVelocityZ);
}
这样就可以保证在斜坡上的运动时的速度了。
二、2D游戏的运动(平台跳跃类)
在2D游戏中,值得探讨的运动问题主要是关于横向平台类的,一般俯视角的2D游戏不会有太复杂的移动方式(也或许只是我没遇到),故不会讨论它。
注意:在「基础问题」部分的展示代码都是基于3D的,但稍加修改也可以用于2D(添加了简单的跳跃功能):
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D))]
public class TestMove2D : MonoBehaviour
{
[SerializeField]
private float maxSpeed = 10f; //最大速度
[SerializeField]
private float maxAcceleration = 10f; //最大加速度
[SerializeField]
private float jumpHeight = 2f;//跳跃能到达的预期高度
private Vector2 jumpDirection;//跳跃的方向
private Rigidbody2D body;
private float playerInput;
private Vector2 targetSpeed;
private Vector2 velocity;
private Vector2 groundNormal;
private bool IsOnGround;
private void Awake()
{
body = GetComponent<Rigidbody2D>();
}
private bool isTryJump;
void Update ()
{
playerInput = Input.GetAxis("Horizontal");
playerInput = Mathf.Min(playerInput, 1);
//记录跳跃按键,这里不能直接用赋值,因为Update执行间隔和FixedUpdate不同
//可能当前按下了跳跃,但FixedUpdate却还没来得及跳起来,却又执行了一次Update
//如果直接用等号赋值,此时就会被视作没有按下跳跃键
//所以,应当严格保证只有在FixedUpdate起跳后,isTryJump才为false
isTryJump |= Input.GetButtonDown("Jump");
}
void OnCollisionStay2D(Collision2D collision)
{
for(int i = 0; i < collision.contactCount; ++i)
{
var normal = collision.GetContact(i).normal;
if(normal.y > 0)
{
IsOnGround = true;
groundNormal += normal;
}
}
}
void FixedUpdate ()
{
velocity = body.velocity;
groundNormal.Normalize();
Move();
Jump();
groundNormal = Vector2.up;
body.velocity = velocity;
IsOnGround = false;
}
void Jump()
{
if(isTryJump)
{
isTryJump = false;
if (IsOnGround)
{
jumpDirection = groundNormal;
}
else
{
return;
}
// vt = 根号(2gh),Unity的y方向重力是-9.81,所以计算时g取个负号,使其成正数
var jumpSpeed = Mathf.Sqrt(2 * -Physics.gravity.y * jumpHeight);
//通过投影获取当前跳跃方向的速度
var curSpeed = Vector2.Dot(velocity, jumpDirection);
if(curSpeed > 0)
{
jumpSpeed = Mathf.Max(jumpSpeed - curSpeed, 0);
}
velocity += jumpSpeed * jumpDirection;
}
}
void Move()
{
var acceleration = maxAcceleration * Time.fixedDeltaTime;
targetSpeed = new Vector2(playerInput, 0f) * maxSpeed;
var xAixs = GetProjectOnPlane(Vector2.right).normalized;
var curVelocityX = Vector2.Dot(velocity, xAixs);
var newVelocityX = Mathf.MoveTowards(curVelocityX, targetSpeed.x, acceleration);
velocity += xAixs * (newVelocityX - curVelocityX);
}
Vector2 GetProjectOnPlane(Vector2 curVector)
{
return curVector - groundNormal * Vector2.Dot(curVector, groundNormal);
}
}
1. 土狼时间
土狼时间允许玩家在 离开平台的短时间内仍可以进行跳跃,它的本质是对玩家操作的「宽容」,很多时候玩家为了跳出更远的距离,会希望在平台边缘极限位置开始跳跃。但这个时机不好把控,一是因为游戏引擎本身物理更新存在间隔,二是人的反应延迟,因此这种「宽容」处理是能优化玩家体验的。
PS:“土狼时间”的名字由来:
土狼时间的实现并不难,关键是要处理好一个细节,即「离开平台」应当是指走出平台,要与因跳跃等其它原因离开平台的情况区分。否则容易出现“二段跳”等问题。
有一种可取的思路是:通过记录离开地面后,FixedUpdate函数执行的次数来作为土狼时间的长度,并也作为跳跃的条件判断依据之一,且在执行一次跳跃后立即置否,以避免“二段跳”(这里顺带实现了跳跃,或许看代码会清楚些):
private int stepsLastGround; //离开地面的时间(以FixedUpdate执行次数来算)
void FixedUpdate ()
{
velocity = body.velocity;
//新增内容,每次进行运动计算前,维护好stepsLastGround的值
stepsLastGround = IsOnGround ? 0 : stepsLastGround + 1;
groundNormal.Normalize();
//其它不变
}
void Jump()
{
if(isTryJump)
{
isTryJump = false;
//可以认为默认情况下一次FixedUpdate用时0.02s
//这里stepsLastGround < 20,则意味着走出平台的时间 < 0.2s (即20 * 0.02s)
if (IsOnGround || stepsLastGround < 10)
{
jumpDirection = groundNormal;
stepsLastGround += 10; //在土狼时间内跳过后,就不能再跳了
}
//其余不变
}
}
比较夸张的实现效果(通常土狼时间不用那么长):
2. 边缘检测
边缘检测同样是对玩家操作的一种宽容手段,它针对的是细微的边缘碰撞带来的问题,例如,玩家想从平台边缘处下方跳跃时,可能会因为细微的碰撞而导致失败:
当然,换个 胶囊体或球体碰撞器 或许也能解决这个问题,只不过会出现 边缘位置站不稳,而且来看看其它解决方法也不亏,没准以后就遇到了不得不使用矩形碰撞器的情况呢。
这个方法很简单:在即将撞到平台时,通过修改位置(而非运动方向)的方式将角色往远离碰撞的位置移一点点,这个过程中玩家的运动是保持着的。因此,如果偏移后的位置合适,玩家看起来就会像被谁助力了一把。
但往那一推是否有效我们得先进行判断:
- 先将物体往远离障碍的地方移动一步;
- 再模拟物体以原本的速度运动一步;
- 如果在执行2后的位置没有碰到障碍,证明可以推,反之不行。
代码实现如下:
[SerializeField]
private LayerMask groundLayer; //设置地面层,用于碰撞检测
void FixedUpdate ()
{
velocity = body.velocity;
stepsLastGround = IsOnGround ? 0 : stepsLastGround + 1;
groundNormal.Normalize();
Move();
Jump();
EdgeDetection();//新内容:边缘检测
groundNormal = Vector2.up;
body.velocity = velocity;
IsOnGround = false;
}
void EdgeDetection()
{
//迈出一步的距离
var move = (Vector3)velocity * Time.fixedDeltaTime;
//继续前进后的位置
var furthestPoint = transform.position + move;
//如果前进后的位置有检测到指定层,说明即将发生碰撞
var hit = Physics2D.OverlapBox(furthestPoint, myCollider.bounds.size, 0, groundLayer);
if (hit)
{
//远离障碍的方向
var dir = (transform.position - hit.transform.position).normalized;
//移动1、2步骤后的位置
var tryPos = furthestPoint + dir * move.magnitude + move;
//如果新位置没有碰撞,说明可以进行偏移
//这里要排除接触地面的情况下,否则会误认为一直有碰撞
if (!IsOnGround && !Physics2D.OverlapBox(tryPos, myCollider.bounds.size, 0, groundLayer))
{
transform.position = transform.position + dir * move.magnitude;
}
}
}
最终效果如下:
顺带一提,这种处理方式也能解决玩家差一点就能跳上平台的情况:
3. 额外重力
平台跳跃类游戏的「跳跃」是比较特殊的,通常我们需要做到以下两点,才能保证有好的跳跃体验而不会觉得很飘:
- 跳跃高度会随按下跳跃键的时间而变化,做到短按跳得矮,久按跳得高(有种通过按键来施加跳跃力的感觉);
- 跳跃下落更干脆,对于同等高度,通常下降的时间会比跳跃上升的时间更短。
通过添加额外的重力,我们可以一齐解决这两个问题。思路是这样的:
- 当玩家处于下落状态时,我们就为它施加额外重力,使下落过程加快,这样就能解决2问题。
- 当玩家在跳跃上升过程中,如果没按住跳跃键,就进一步施加更大重力,这样短按就跳得矮了,相对的长按就跳得高,解决1问题。
[SerializeField]
private float maxFallSpeed = 40; //最大下落速度
[SerializeField]
private float fallAcceleration = 110; //下落加速度
[SerializeField]
private float jumpEndFactor = 3; //跳跃过程中短按带来的额外重力放大倍数
[SerializeField]
private float fallFactor = 0.3f;//下落时的额外重力放大倍数
void ExtraGravity()
{
var jumpSpeed = Vector2.Dot(velocity, jumpDirection);
if (!IsOnGround) //不在地面,即处于上升或下降状态时
{
var inAirGravity = fallAcceleration;
if(jumpSpeed > 0f) //如果上升
{
if (!Input.GetButton("Jump")) //上升过程中,没按跳跃键时会受到额外重力
{
inAirGravity *= jumpEndFactor;
}
}
else
{
inAirGravity *= fallFactor;
}
jumpSpeed = Mathf.MoveTowards(jumpSpeed, -maxFallSpeed, inAirGravity * Time.fixedDeltaTime) - jumpSpeed;
velocity += jumpSpeed * jumpDirection;
}
}
让我们看看最后效果如何(可以自行修改相关参数来调整跳跃效果):
但注意,经过这样的修改后,原本用于设置跳跃高度的jumpHeight就不准确了,jumpHeight此时只能当跳跃力来看待。