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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章