角色模型(上)
这里会描述如何为人物添加动作逻辑
导入角色模型
这次使用的角色模型是普通的人类士兵模型,没有飞行的动作,因此需要对先前的人物模型进行一些改动
修改跳跃模式
进入 Prefabs
找到 Player
,将 Player
组件里的 Configurable Joint
删掉。之后在 Rigidbody
中勾选 Use Gravity
之后是修改 PlayerInput
。将 Configurable Joint
的引用变量删除。将弹跳的输入代码修改如下
if (Input.GetButton("Jump"))
{
Vector3 force = Vector3.up * thrusterForce; // 力是一个三维向量
controller.Thrust(force);
}
在 PlayerController
中,施加向上跳跃的力后将力清空
这时一直按着空格还是可以飞起来,因为我们没有给跳跃加上条件,只有当角色没有离地时才能跳跃。要判断角色有没有离地,可以使用和射击相似的逻辑,从角色中心向角色脚底发出一条射线,当射线在长度超过角色身体的时候触碰到了物体,说明角色双脚离地,此时不能跳跃。
获取角色中心发出射线到角色脚底的长度:
distToGround = GetComponent<Collider>().bounds.extents.y;
如果觉得下落速度比较慢,可以在右上角的 Edit -> Project Settings
里面点击左边的 Physics
,调整里面的y轴的重力数值。
如果跳跃高度比较低,可以在 Player
里的 Player Input
组件中将向上的冲力调高
将球变成角色模型
角色资源
如果是白嫖资源,从网盘上下载下来后,是一个 .unitypackage
文件。在unity编辑器中,左上角 Assets -> Import Package
中点击 Custom Package
,然后选择下载好的.unitypackage
文件,之后选择 import
即可
点击 Prefabs
里的 Player
模型,将物体 Graphics
里的 Sphere
删除,选择导入的资料 LowPolySoldiers
里的 Soldier_all_parts
模型,拖进 Graphics
中。
现在 Player
的碰撞检测是一个球体,一般人物的碰撞检测需要改成胶囊。将 Player
的 Sphere Collider
组件删除,添加 Capsule Collider
组件,修改碰撞检测胶囊的长度和位置,包裹住人的身体。
修改玩家不能跳跃的bug
如果是通过碰撞胶囊移动位置去囊括角色模型来设置碰撞体积的话,可能会产生坐标计算的错误导致不能跳跃。这是可以恢复碰撞胶囊的y坐标为0,将角色往下移,即可修复。
补丁:不让玩家看到自己身体内部
目前角色的摄像机可以看到所有图层,因此玩家如果将视角向下看可以看到角色身体内部。为了不让玩家看到角色身体内部,可以在 Player
里添加图层。选择 Prefabs
里的 Player
,然后在右边 Layer
里点击 Add Layer
点进去之后添加一个本地玩家图层,一个联网玩家图层。添加两个图层是因为我们不想看到的是本地玩家的图层而不是联网玩家的图层,因此需要区分
然后在摄像机能看到的图层里将玩家图层去掉即可。
在 PlayerSetup
里区分图层
private void SetLayerMaskForAllChildren(Transform transform, LayerMask layerMask)
{
// 这里要改掉Player的图层。每个物体的图层和子物体的图层是独立的,因此要修改Player整体的图层,需要对所有子物体进行修改
// 这里使用dfs实现
transform.gameObject.layer = layerMask;
for (int i = 0; i < transform.childCount; i++)
{
SetLayerMaskForAllChildren(transform.GetChild(i), layerMask);
}
}
在 OnNetworkSpawn()
函数里,判断如果不是本地玩家,就将图层设置为联网玩家,否则设置为本地玩家。
添加角色动画
创建并添加 Animator Controller
在 Prefabs
的 Player
物体里,点击我们导入的角色模型,看到右边属性,可以看到 Animator
,这是我们控制角色动画的地方,我们需要创建一个 Controller
去控制动画。
在下方资源栏右键,选择创建 Create
里的 Animator Controller
将创建好的 Animator Controller
拖到右边的 Controller
里
双击打开刚刚创建好的 Animator Controller
角色动画控制:有限状态自动机(DFA)
unity里的角色动画使用一个有限状态自动机(DFA)来进行管理。Entry
是自动机的入口。假设目前角色有三种动画节点,分别是静止不动 idle
, 前进 forward
和后退 back
。每个动画节点做的事就是重复播放该节点的动画。从入口开始,没有接收到任何输入,通过一条有向边,角色状态从入口到 idle
节点。idle
,forward
和 back
两两之间可以相互到达。比如 idle
节点检测到用户按下前进键,就转移到 forward
节点,松开前进键且没按下其他键就转到 idle
。
构建DFA
我们先来实现上述的动画自动机。选择 LowPolySoldier -> animations -> Assault
里的 一个角色在原地不动的动画 Assault_combat_idle_aim Import Settings
拖进 Animator Controller
中。在 LowPolySoldier -> animations -> Assault -> movement
里找到前进和后退的动画,也拖进 Animator Controller
中。
第一个加入进 Animator Controller
的状态会被设定为默认状态,自动与 Entry
之间有一条连线。如果想更改默认状态,可以选择一个非默认状态,右键后选择 Set as Layer Default State
这里设定静止为默认状态。想在两个状态之间添加连线,比如这里想从静止到向前跑之间添加连线,就右键静止状态,点击 Make Transition
,这时会出现一个连线,连线终点跟随鼠标移动。将鼠标移动到向前跑的状态并点击,这时就创建了一条从静止到向前跑的有向边。
同理,按照上述画的图连接各个状态。
添加转换条件
点击一条边,看到右边的转换边的参数
下方是时间轴,时间轴上,一个动画转换为了另一个动画。注意,两个动画的时间之间有重叠,这是unity自动帮我们做的动画平滑处理,这个重叠的时间里,unity会先计算两个动画之间模型各个部位之间的位置差值,然后在这一段时间里平均地移动各个部位,使动画转化变得平滑。上方的 Has Exit Time
需要取消,否则动画的转化会有延迟。
在为动画转化添加条件前,先要明确,我们的角色移动会有9种状态,分别是静止、前、右前、右、右后、后、左后、左、左前,分别给它们编号
(我们的动画包里没有右前和左前的动画,暂时不实现)
在页面左边的 Parameters
下的加号里选择 Int
创建一个整形变量 direction
选择从静止到前进的边,在右边的 Conditions
里点击加号添加条件,选择 Equals
并填写1,表示 direction
等于1为该条边的转化条件。
其他边设置同理。
动画改变
在 PlayerController
中进行动画改变,因为对于角色移动,我们采用的是Client模式,本地玩家收到输入后,改变角色下一帧的位置,并将这个位置同步给其他所有玩家,因此其他玩家在不知道本地玩家的输入的情况下想改变动画,需要通过角色位置的改变来完成。
在 PlayerController
中添加一个变量记录角色上一帧的位置。创建一个变量取出 Animator
组件,需要使用 GetComponentInChildren
创建一个改变动画的函数 PerformAnimation()
private void PerformAnimation()
{
Vector3 deltaPosition = transform.position - lastFramePosition; // 计算坐标差值
lastFramePosition = transform.position; // 更新上一帧位置
float forward = Vector3.Dot(deltaPosition, transform.forward); // 通过向量点乘计算坐标差值在前进方向上的分量,为正表示向前,负表示向后
float right = Vector3.Dot(deltaPosition, transform.right); // 计算坐标差值在右方向上的分量,为正表示向右,负表示向左
int direction = 0; // 静止
if (forward > eps) // 这里不直接和0比较,是防止精度导致判断错误, 这里认为参数在[-eps, eps] 之间时值为0
{
direction = 1; // 前
}
else if (forward < -eps)
{
if (right > eps)
{
direction = 4; // 右后
}
else if (right < -eps)
{
direction = 6; // 左后
}
else
{
direction = 5; // 后
}
}
else if (right > eps)
{
direction = 3; // 右
}
else if (right < -eps)
{
direction = 7; // 左
}
animator.SetInteger("direction", direction); // 将变量值更新到动画中,引号里的字符串要和编辑器里设定的变量名称一致
}
如果感觉动画转变有延迟,可以将边的 Transition Duration
改小
优化DFA结构
我们一共有7种要实现的状态,两两可以互相转化,因此如果按照现在的DFA结构,会有非常多的边。如果DFA的节点两两都可以转化,那么可以使用任意状态节点 Any State
去连接每一个节点,表示任意节点都可以转移,这时边数就等于我们要实现的状态数了。连好线后进行上述的同样的属性设置。
此时如果我们运行游戏,移动角色,会发现角色在前进和后退时动画是小碎步状态,这是因为 Any State
在转化到某一状态时,也包括自己转化到自己,比如在前进时,前进的状态又不断转化到前进,使得前进的动画无法播放完整。要解决这个问题,需要将边属性里的 Can Transition to Self
取消
其他方向的实现同理
远程同步
由于联机玩家的 PlayerController
被禁用了,因此动画无法同步。首先要在 Player
物体的 Player Setup
组件中将 Player Controller
从 Component To Disable
中删去。
在 PlayerController
中,将 PlayerController
继承的类改为 NetworkBehaviour
。在 FixedUpdate()
中,判断,如果是本地玩家才进行移动。这样动画就可以联机同步了。
动画执行位置
观察联机玩家的动作,会发现联机玩家的动作存在小碎步。这是因为我们将执行动画的函数放到了 FixedUpdate()
里执行。动画是按实际帧渲染的,而在两个实际帧之间可能间隔了多个固定帧,这几个固定帧会多次调用动画执行的函数,导致两个真实帧的动画会出现动作不连贯、无法完成整个动作的情况发生。要解决这个问题,只用将执行动画的函数 PerformAnimation()
放到 Update()
函数里执行即可
(按照上述修改后,我在unity里运行时角色动作会多次调用,但是联机看动作是正常的)
阶段性成品
修改的代码
PlayerController.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
public class PlayerController : NetworkBehaviour
{
[SerializeField]
private Rigidbody rb; // 加上注解后在unity里将要绑定的rigidbody拖进变量中
[SerializeField]
private Camera cam; // 要操作视角,需要摄像头
private Vector3 velocity = Vector3.zero; // 速度:每秒移动的距离,初始为0(一定要清楚速度对应的时间单位是帧还是秒)
private Vector3 yRotation = Vector3.zero; // 旋转角色
private Vector3 xRotation = Vector3.zero; // 旋转视角
private Vector3 thrusterForce = Vector3.zero; // 向上的推力
private float recoilForce = 0f; // 后坐力
private float cameraRotationTotal = 0f; // 累计转了多少度
[SerializeField]
private float cameraRotationLimit = 85f;
private Vector3 lastFramePosition = Vector3.zero; // 上一帧角色的位置
private float eps = 0.01f; // 精度
private Animator animator; // 动画
private void Start()
{
lastFramePosition = transform.position;
animator = GetComponentInChildren<Animator>();
}
public void Move(Vector3 _velocity)
{
velocity = _velocity; // 将用户输入的速度向量传过来
}
public void Rotate(Vector3 _yRotation, Vector3 _xRotation) // 将用户输入的旋转向量传过来
{
yRotation = _yRotation;
xRotation = _xRotation;
}
public void Thrust(Vector3 _thrusterForce)
{
thrusterForce = _thrusterForce;
}
public void AddRecoilForce(float newRecoilForce) // 累加后坐力
{
recoilForce += newRecoilForce;
}
private void PerformMovement()
{
if (velocity != Vector3.zero) // 这里是一个优化,只有当角色有速度时才进行移动操作
{
rb.MovePosition(rb.position + velocity * Time.fixedDeltaTime);
}
if (thrusterForce != Vector3.zero)
{
rb.AddForce(thrusterForce); // 作用Time.fixedDeltaTime秒:0.02s
thrusterForce = Vector3.zero;
}
}
private void PerformRotation()
{
if (recoilForce < 0.1)
{
recoilForce = 0f; // 当后坐力小于0.1时置为0
}
if (yRotation != Vector3.zero || recoilForce > 0)
{
rb.transform.Rotate(yRotation + rb.transform.up * Random.Range(-2f * recoilForce, 2f * recoilForce)); // 旋转刚体,后坐力使枪口随机左右摆动
}
if (xRotation != Vector3.zero || recoilForce > 0)
{
cameraRotationTotal += xRotation.x - recoilForce; // 旋转时添加了后坐力
cameraRotationTotal = Mathf.Clamp(cameraRotationTotal, -cameraRotationLimit, cameraRotationLimit);
cam.transform.localEulerAngles = new Vector3(cameraRotationTotal, 0f, 0f); // 不旋转刚体,只旋转摄像头
}
recoilForce *= 0.5f; // 后坐力递减
}
private void PerformAnimation()
{
Vector3 deltaPosition = transform.position - lastFramePosition; // 计算坐标差值
lastFramePosition = transform.position; // 更新上一帧位置
float forward = Vector3.Dot(deltaPosition, transform.forward); // 通过向量点乘计算坐标差值在前进方向上的分量,为正表示向前,负表示向后
float right = Vector3.Dot(deltaPosition, transform.right); // 计算坐标差值在右方向上的分量,为正表示向右,负表示向左
int direction = 0; // 静止
if (forward > eps) // 这里不直接和0比较,是防止精度导致判断错误, 这里认为参数在[-eps, eps] 之间时值为0
{
direction = 1; // 前
}
else if (forward < -eps)
{
if (right > eps)
{
direction = 4; // 右后
}
else if (right < -eps)
{
direction = 6; // 左后
}
else
{
direction = 5; // 后
}
}
else if (right > eps)
{
direction = 3; // 右
}
else if (right < -eps)
{
direction = 7; // 左
}
animator.SetInteger("direction", direction); // 将变量值更新到动画中,引号里的字符串要和编辑器里设定的变量名称一致
}
private void FixedUpdate() // 每秒中以固定时间间隔执行固定次数
{
if (IsLocalPlayer) // 只有本地玩家才可以移动
{
PerformMovement();
PerformRotation();
}
}
private void Update()
{
PerformAnimation();
}
}
PlayerInput.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerInput : MonoBehaviour
{
[SerializeField]
private float speed = 5f; // 加上SerializeField注解,可以在unity里调整speed的值
[SerializeField]
private float lookSensitivity = 50f; // 鼠标灵敏度
[SerializeField]
private float thrusterForce = 20f; // 角色向上推力
[SerializeField]
private PlayerController controller;
private float distToGround = 0f; // 角色中心发出射线到角色脚底的长度
// Start is called before the first frame update
void Start()
{
Cursor.lockState = CursorLockMode.Locked;
distToGround = GetComponent<Collider>().bounds.extents.y;
}
// Update is called once per frame
void Update()
{
float xMove = Input.GetAxisRaw("Horizontal"); // 获取用户在x轴上的移动方向,1表示右,-1表示左,0表示不动
float zMove = Input.GetAxisRaw("Vertical"); // 获取用户在z轴上的移动方向,1表示前,-1表示后,0表示不动
Vector3 velocity = (transform.right * xMove + transform.forward * zMove).normalized * speed;
controller.Move(velocity); // 将速度传递给PlayerController来实现运动
// 获取鼠标移动数据
float xMouse = Input.GetAxisRaw("Mouse X"); // 鼠标水平移动
float yMouse = Input.GetAxisRaw("Mouse Y"); // 鼠标垂直移动
// Debug.Log(xMouse.ToString() + " " + yMouse.ToString()); // 调试信息
Vector3 yRotation = new Vector3(0f, xMouse, 0f) * lookSensitivity; // 角色y轴旋转向量
Vector3 xRotation = new Vector3(-yMouse, 0f, 0f) * lookSensitivity; // 角色x轴旋转向量
controller.Rotate(yRotation, xRotation);
if (Input.GetButton("Jump"))
{
if (Physics.Raycast(transform.position, -Vector3.up, distToGround + 0.1f)) // +0.1是防止判断时由于精度问题出错
{
Vector3 force = Vector3.up * thrusterForce; // 力是一个三维向量
controller.Thrust(force);
}
}
}
}
PlayerSetup.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Netcode;
public class PlayerSetup : NetworkBehaviour
{
[SerializeField]
private Behaviour[] componentsToDisable;
[SerializeField]
private Camera sceneCamera;
// Start is called before the first frame update
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
if (!IsLocalPlayer)
{
SetLayerMaskForAllChildren(transform, LayerMask.NameToLayer("Remote Player")); // 如果不是本地玩家,就将图层设置为联网玩家
DisableComponents();
}
else
{
SetLayerMaskForAllChildren(transform, LayerMask.NameToLayer("Player")); // 本地玩家,将图层设置为本地玩家
sceneCamera = Camera.main;
if (sceneCamera != null)
{
sceneCamera.gameObject.SetActive(false);
}
}
string name = "Player " + GetComponent<NetworkObject>().NetworkObjectId.ToString();
Player player = GetComponent<Player>();
player.Setup();
GameManager.Singleton.RegisterPlayer(name, player);
}
private void SetLayerMaskForAllChildren(Transform transform, LayerMask layerMask)
{
// 这里要改掉Player的图层。每个物体的图层和子物体的图层是独立的,因此要修改Player整体的图层,需要对所有子物体进行修改
// 这里使用dfs实现
transform.gameObject.layer = layerMask;
for (int i = 0; i < transform.childCount; i++)
{
SetLayerMaskForAllChildren(transform.GetChild(i), layerMask);
}
}
private void DisableComponents()
{
for (int i = 0; i < componentsToDisable.Length; i++)
{
componentsToDisable[i].enabled = false;
}
}
public override void OnNetworkDespawn()
{
base.OnNetworkDespawn();
if (sceneCamera != null)
{
sceneCamera.gameObject.SetActive(true);
}
GameManager.Singleton.UnregisterPlayer(transform.name);
}
}
著作权信息
- 源视频作者:yxc
- 文章作者: jaylenwanghitsz
- 链接:https://www.acwing.com/file_system/file/content/whole/index/content/8704531/
- 来源:AcWing
- 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。