上一章地址:UnityStandardAsset工程、源码分析_6_第三人称场景[玩家控制]_工程组织
上一章里,我们花了一整章的篇幅用于分析场景的结构和处理流程,并且确定了本章的分析目标:ThirdPersonUserControl
和ThirdPersonCharacter
这两个脚本。那么接下来我们就来分析由它们构成的核心人物逻辑。
这个脚本在我们上一章的分析中已经确认了,这是类似于CarUserControl
的用户接口脚本,用于从用户处读取原始输入数据,再进行处理,最后使用这些数据在每一帧调用ThirdPersonCharacter
的Move
方法实现人物状态的更新。
先来看它用于初始化的Start
方法:
private void Start()
{
// 需要使用到摄像机
// get the transform of the main camera
if (Camera.main != null)
{
m_Cam = Camera.main.transform;
}
else
{
Debug.LogWarning(
"Warning: no main camera found. Third person character needs a Camera tagged \"MainCamera\", for camera-relative controls.", gameObject);
// 这样的话我们就会使用self-relative控制,可能不是用户想要的
// we use self-relative controls in this case, which probably isn't what the user wants, but hey, we warned them!
}
// 需要与ThirdPersonCharacter协同工作
// get the third person character ( this should never be null due to require component )
m_Character = GetComponent<ThirdPersonCharacter>();
}
可见这个脚本需要使用到摄像机来处理输入数据。那为什么需要摄像机呢?我们可以想象一个场景:使用了自由摄像机,你按住了W键,人物笔直的向着你摄像机面朝的方向前进,当你用鼠标左右旋转视野时,人物会不断调整方向,始终是朝着摄像机面朝的方向,而不是根据A键或是D键来进行转向。在另一个场景里,你使用了上帝视角,就类似于RTS的视角,无法旋转,只能够平移。这时WASD四个键就对应了不同的方向,两两组合可以让人物朝着八个方向移动,而无论你怎么平移摄像机,一个键只对应一个方向,而不是使用自由摄像机时W键的方向会随着你摄像机的旋转而调整。
不过这两个场景可能与代码描述的有些出入,代码里是通过Camera.main
来获取自由摄像机,获取不到就使用self-relative
,也就是我们上面描述的RTS视角来进行输入数据的处理。这就导致就算使用了自由摄像机,而它没有被设置成MainCamera
,也一样使用RTS视角;或是使用了固定摄像机,却设置成MainCamera
,而采用了自由模式。这里的设计就不是很好,为什么不先获取主摄像机再判断是不是自由摄像机呢?
我们再来看它的Update
和FixedUpdate
:
private void Update()
{
if (!m_Jump)
{
m_Jump = CrossPlatformInputManager.GetButtonDown("Jump");
}
}
// Fixed update is called in sync with physics
private void FixedUpdate()
{
// 读取输入
// read inputs
float h = CrossPlatformInputManager.GetAxis("Horizontal");
float v = CrossPlatformInputManager.GetAxis("Vertical");
bool crouch = Input.GetKey(KeyCode.C); // 这里把键位写死了,为什么和上面一样?
// 计算角色的前进方向
// calculate move direction to pass to character
if (m_Cam != null)
{
// 计算依赖于摄像机的方向来移动
// calculate camera relative direction to move:
m_CamForward = Vector3.Scale(m_Cam.forward, new Vector3(1, 0, 1)).normalized;
m_Move = v*m_CamForward + h*m_Cam.right;
}
else
{
// 没有摄像机的话就使用基于世界坐标的方向
// we use world-relative directions in the case of no main camera
m_Move = v*Vector3.forward + h*Vector3.right;
}
#if !MOBILE_INPUT
// 左shift慢走
// walk speed multiplier
if (Input.GetKey(KeyCode.LeftShift)) m_Move *= 0.5f;
#endif
// 把所有数据传给角色控制器
// pass all parameters to the character control script
m_Character.Move(m_Move, crouch, m_Jump);
m_Jump = false;
}
这里有很重要的一点:它的跳跃输入处理是放在Update
中的。为什么呢?这跟玩家的输入方式有关,我们想按下空格让人物跳跃的话,仅需要在极短的时间内按下空格,再放开,而不是一直按住空格。这就造成了一个问题,FixedUpdate
是按照固定的时间间隔调用的,两次调用之间通常相隔了好几帧,如果玩家按下空格再抬起来的这个动作消耗的时间小于FixedUpdate
的调用时间间隔,这个输入就不会被下一个FixedUpdate
接收到。不过这个问题当然可以通过缩小时间间隔来解决,但这样耗费的代价就太大了,物理计算还是很消耗资源的。那么这里的处理方式就是把读取输入的工作放在了Update
内,那为什么这样就不会丢失输入了呢?如果玩家“单帧操作”怎么办?这是因为在两次Update
之间,并没有额外的数据读入操作,键盘的输入会一直存在与缓冲区中,等待下一个Update
的读取,而两次FixedUpdate
之间通常会经过了好几轮Update
,数据已经被Update
读走了,下一个FixedUpdate
自然就读取不到输入了。
此外,由代码可见,ThirdPersonUserControl
传给ThirdPersonCharacter
的是矢量移动方向,指示人物应当往哪个方向移动,还有布尔类型的crouch
和m_Jump
,代表玩家是否发出了蹲伏和跳跃的指令。ThirdPersonUserControl
的使命到此结束,接下来我们看看ThirdPersonCharacter
的代码,读核心逻辑首先从Move
方法开始:
public void Move(Vector3 move, bool crouch, bool jump)
首先把世界坐标转本地坐标,方便计算:
// 把世界坐标下的输入向量转换成本地坐标下的旋转量和向前量
// convert the world relative moveInput vector into a local-relative
// turn amount and forward amount required to head in the desired
// direction.
if (move.magnitude > 1f) move.Normalize(); // 归一化
move = transform.InverseTransformDirection(move); // 世界坐标转本地
然后进行脚下的检测,用于判断人物是否滞空:
CheckGroundStatus(); // 检查脚下
void CheckGroundStatus()
{
// 检测脚下地面的函数,用于 浮空/跳跃 等状态的实现
RaycastHit hitInfo;
#if UNITY_EDITOR
// 画出检测线
// helper to visualise the ground check ray in the scene view
Debug.DrawLine(transform.position + (Vector3.up * 0.1f), transform.position + (Vector3.up * 0.1f) + (Vector3.down * m_GroundCheckDistance));
#endif
// 0.1f是个很小的向角色内部的偏移,运行一下就可以在editor里观察到
// 0.1f is a small offset to start the ray from inside the character
// it is also good to note that the transform position in the sample assets is at the base of the character
// 从偏移后的坐标向下发射射线
if (Physics.Raycast(transform.position + (Vector3.up * 0.1f), Vector3.down, out hitInfo, m_GroundCheckDistance))
{
// 脚下是地面
m_GroundNormal = hitInfo.normal;
m_IsGrounded = true;
m_Animator.applyRootMotion = true;
}
else
{
// 不是地面
m_IsGrounded = false;
m_GroundNormal = Vector3.up;
m_Animator.applyRootMotion = false;
}
}
这个方法画出了检测线,判断脚下是否为地面,计算脚下地面的法向量,还有设置了动画状态机的applyRootMotion
,这里就是上一章的由脚本来控制是否启用根动画的部分。可以看到,这里规定在地面上可以使用根动画,而在空中不行。这是因为人物在空中的动画也是包含了位移的,但这个位移只能朝向人物模型的前方,从而无法实现侧向的跳跃,就是人朝前,速度向两边。这里判断人物在空中后,关闭了根动画,并且使用OnAnimatorMove
回调函数进行速度的处理:
public void OnAnimatorMove()
{
// 我们实现这个方法来重写默认根动画的运动
// 这允许我们在速度被应用前修改它
// we implement this function to override the default root motion.
// this allows us to modify the positional speed before it's applied.
if (m_IsGrounded && Time.deltaTime > 0)
{
Vector3 v = (m_Animator.deltaPosition * m_MoveSpeedMultiplier) / Time.deltaTime;
// 保存当前的y轴速度
// we preserve the existing y part of the current velocity.
v.y = m_Rigidbody.velocity.y;
m_Rigidbody.velocity = v;
}
}
由此实现了侧向跳跃。
我们继续进行Move
的分析:
move = Vector3.ProjectOnPlane(move, m_GroundNormal); // 把移动向量投影到地面上
m_TurnAmount = Mathf.Atan2(move.x, move.z); // 绕y轴旋转量
m_ForwardAmount = move.z; // 前进量
这里将移动的方向投影到了由之间计算的法向量m_GroundNormal
定义的平面上,这是为了提供上坡和下坡的功能,人物移动的方向总是平行于地面的。并且计算了旋转量,用于提供给Animator
,混合出相应的转向动画。
接下来调用了ApplyExtraTurnRotation
方法,加强了旋转,用于优化手感:
ApplyExtraTurnRotation(); // 转向
void ApplyExtraTurnRotation()
{
// 帮助角色转得更快,角度越大越快
// help the character turn faster (this is in addition to root rotation in the animation)
float turnSpeed = Mathf.Lerp(m_StationaryTurnSpeed, m_MovingTurnSpeed, m_ForwardAmount);
transform.Rotate(0, m_TurnAmount * turnSpeed * Time.deltaTime, 0);
}
然后根据人物的状态调用不同的处理函数:
// 速度的处理在滞空状态和在地面上是不同的
// control and velocity handling is different when grounded and airborne:
if (m_IsGrounded)
{
HandleGroundedMovement(crouch, jump); // 地面模式
}
else
{
HandleAirborneMovement(); // 滞空模式
}
我们先来看在地上的HandleGroundedMovement
方法:
void HandleGroundedMovement(bool crouch, bool jump)
{
// 在地面上的移动
// 检查跳跃的条件
// check whether conditions are right to allow a jump:
if (jump && !crouch && m_Animator.GetCurrentAnimatorStateInfo(0).IsName("Grounded"))
{
// 跳跃,速度的y轴分量设为m_JumpPower
// jump!
m_Rigidbody.velocity = new Vector3(m_Rigidbody.velocity.x, m_JumpPower, m_Rigidbody.velocity.z);
m_IsGrounded = false;
m_Animator.applyRootMotion = false;
m_GroundCheckDistance = 0.1f;
}
}
这里将m_GroundCheckDistance
赋值成了0.1f,这个变量用在了CheckGroundStatus
中,用于根据速度延长地面检测线,避免速度过快发生的穿模问题。这也被定义在了滞空移动方法HandleAirborneMovement
中:
void HandleAirborneMovement()
{
// 给予额外的重力
// apply extra gravity from multiplier:
Vector3 extraGravityForce = (Physics.gravity * m_GravityMultiplier) - Physics.gravity;
m_Rigidbody.AddForce(extraGravityForce);
m_GroundCheckDistance = m_Rigidbody.velocity.y < 0 ? m_OrigGroundCheckDistance : 0.01f;
}
可见人物在天上的时候被给予了一个额外的重力,这是由于人物跳起两三米的话按照现实的重力来说下落得太慢了,需要根据游戏性做出调整。
再来看Move
方法的下一条语句:
ScaleCapsuleForCrouching(crouch); // 蹲伏处理
void ScaleCapsuleForCrouching(bool crouch)
{
// 蹲伏时缩放胶囊碰撞盒
if (m_IsGrounded && crouch)
{
// 在地面上而且主动蹲伏
// 不在蹲伏的话 碰撞盒高度和位置/2f
if (m_Crouching) return;
m_Capsule.height = m_Capsule.height / 2f;
m_Capsule.center = m_Capsule.center / 2f;
m_Crouching = true;
}
else
{
// 站起来,在狭小的空间内则不能站立
Ray crouchRay = new Ray(m_Rigidbody.position + Vector3.up * m_Capsule.radius * k_Half, Vector3.up);
float crouchRayLength = m_CapsuleHeight - m_Capsule.radius * k_Half;
if (Physics.SphereCast(crouchRay, m_Capsule.radius * k_Half, crouchRayLength, Physics.AllLayers, QueryTriggerInteraction.Ignore))
{
m_Crouching = true;
return;
}
m_Capsule.height = m_CapsuleHeight;
m_Capsule.center = m_CapsuleCenter;
m_Crouching = false;
}
}
这里就进行了蹲伏相关的处理操作。
然后:
PreventStandingInLowHeadroom(); // 在狭小的空间内自动蹲下
void PreventStandingInLowHeadroom()
{
// 阻止在仅允许蹲伏的区域内站立
// 也就是进入狭小的空间,而人物没有蹲伏则自动蹲下
// prevent standing up in crouch-only zones
if (!m_Crouching)
{
Ray crouchRay = new Ray(m_Rigidbody.position + Vector3.up * m_Capsule.radius * k_Half, Vector3.up);
float crouchRayLength = m_CapsuleHeight - m_Capsule.radius * k_Half;
if (Physics.SphereCast(crouchRay, m_Capsule.radius * k_Half, crouchRayLength, Physics.AllLayers, QueryTriggerInteraction.Ignore))
{
m_Crouching = true;
}
}
}
如上的一系列处理已经更新好了人物的状态数据,接下来就是根据这些状态数据来更新动画状态机的参数,让动画状态机混合和播放出相应的动画:
// send input and other state parameters to the animator
UpdateAnimator(move);
void UpdateAnimator(Vector3 move)
{
// 更新动画状态机的参数
// update the animator parameters
m_Animator.SetFloat("Forward", m_ForwardAmount, 0.1f, Time.deltaTime);
m_Animator.SetFloat("Turn", m_TurnAmount, 0.1f, Time.deltaTime);
m_Animator.SetBool("Crouch", m_Crouching);
m_Animator.SetBool("OnGround", m_IsGrounded);
if (!m_IsGrounded)
{
m_Animator.SetFloat("Jump", m_Rigidbody.velocity.y);
}
// 计算哪条腿在后面,就可以让那条腿在跳跃动画播放的时候留在后面
// (这些代码依赖于我们动画具体的跑步循环偏移)
// 并且假设一条对在归一化的时间片[0,0.5]内经过经过另一条腿
// calculate which leg is behind, so as to leave that leg trailing in the jump animation
// (This code is reliant on the specific run cycle offset in our animations,
// and assumes one leg passes the other at the normalized clip times of 0.0 and 0.5)
// 其实就是根据动画判断哪条腿在后面,由于动画是镜像的,[0,0.5]内是一条腿,[0.5,1]是另一条腿
float runCycle =
Mathf.Repeat(
m_Animator.GetCurrentAnimatorStateInfo(0).normalizedTime + m_RunCycleLegOffset, 1);
float jumpLeg = (runCycle < k_Half ? 1 : -1) * m_ForwardAmount;
if (m_IsGrounded)
{
m_Animator.SetFloat("JumpLeg", jumpLeg);
}
// 这里可以调整行走和跑步的速度
// the anim speed multiplier allows the overall speed of walking/running to be tweaked in the inspector,
// which affects the movement speed because of the root motion.
if (m_IsGrounded && move.magnitude > 0)
{
m_Animator.speed = m_AnimSpeedMultiplier;
}
else
{
// 空中不可调整
// don't use that while airborne
m_Animator.speed = 1;
}
}
关于这个场景的一切分析就结束了,有了之前分析过的框架的基础,速度就快了很多,因为场景本身代码也挺少的。其实这个场景还有一些bug,比如人物跑步下坡的时候并非一直贴着地面,而是类似于跳台阶一样一点一点往下跳,此时玩家的游戏体验就会比较差,要解决这个问题多半就要采取强制改变速度方向的处理了,这又变得更加麻烦。不过人物操作本身就挺难做的,就连Capcom那样的公司也不能完全处理好人物的运动和用户刁钻的输入,怪猎W成天出bug,我都见怪不怪了。下一章分析AI控制的第三人称场景。