UnityStandardAsset工程、源码分析_7_第三人称场景[玩家控制]_人物逻辑

上一章地址:UnityStandardAsset工程、源码分析_6_第三人称场景[玩家控制]_工程组织
上一章里,我们花了一整章的篇幅用于分析场景的结构和处理流程,并且确定了本章的分析目标:ThirdPersonUserControlThirdPersonCharacter这两个脚本。那么接下来我们就来分析由它们构成的核心人物逻辑。


ThirdPersonUserControl

这个脚本在我们上一章的分析中已经确认了,这是类似于CarUserControl的用户接口脚本,用于从用户处读取原始输入数据,再进行处理,最后使用这些数据在每一帧调用ThirdPersonCharacterMove方法实现人物状态的更新。
先来看它用于初始化的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,而采用了自由模式。这里的设计就不是很好,为什么不先获取主摄像机再判断是不是自由摄像机呢?

我们再来看它的UpdateFixedUpdate

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的是矢量移动方向,指示人物应当往哪个方向移动,还有布尔类型的crouchm_Jump,代表玩家是否发出了蹲伏和跳跃的指令。ThirdPersonUserControl的使命到此结束,接下来我们看看ThirdPersonCharacter的代码,读核心逻辑首先从Move方法开始:


ThirdPersonCharacter

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控制的第三人称场景。

你可能感兴趣的:(工程,源码分析)