上一章地址:UnityStandardAsset工程、源碼分析_6_第三人稱場景[玩家控制]_工程組織
上一章裏,我們花了一整章的篇幅用於分析場景的結構和處理流程,並且確定了本章的分析目標:ThirdPersonUserControl
和ThirdPersonCharacter
這兩個腳本。那麼接下來我們就來分析由它們構成的核心人物邏輯。
ThirdPersonUserControl
這個腳本在我們上一章的分析中已經確認了,這是類似於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
方法開始:
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控制的第三人稱場景。