JU-TPS研究笔记
JU TPS研究笔记
这个模板的Cover Demo和尘白禁区一样,是自由观察和背后锁定视角可切换的TPS。这种模式比单独做自由观察或背后锁定都要复杂。在非瞄准也就是自由观察状态,鼠标控制相机转动,WASD控制人物以相机前方为前方一边移动一边平滑旋转到面对移动方向。在瞄准/背后锁定状态
这个模板武器绑定在右手,使用IK摆放右手到合适的状态。
改变仰角
使用了一个Weapon Aim Rotation Center对象负责记录拿各种武器的IKGoal对象,它的脚本里面有两个List分别是这类武器IK位置的名字和Transform:
这个Weapon Aim Rotation Center对象没运行时是人物的子对象,开机瞄后变成根对象了。
它会随鼠标上下移动改变仰角,把各种武器的IK目标带到合适的位置旋转。
趴下时会改变Weapon Aim Rotation Center的位置和旋转。
然后查看人物脚本的IK部分:
void OnAnimatorIK(int layerIndex)
{
if (IsDead || InverseKinematics == false) return;
//Get Original Spine Rotation
OriginalSpineRotation = anim.GetBoneTransform(HumanBodyBones.Spine).transform.localRotation;
//Firing Mode IK
if (IsRolling == false && IsDriving == false)
{
//If you have problems with the elbow ik on your left arm, uncomment the line below
//LeftHandToRespectiveIKPosition(LeftHandWeightIK, LeftHandWeightIK / 1.2f);
LeftHandToRespectiveIKPosition(LeftHandWeightIK, 0);
RightHandToRespectiveIKPosition(RightHandWeightIK, RightHandWeightIK / 1.2f);
Vector3 LookingPosition = GetLookPosition();
float BodyWeight = (IsProne) ? 0.1f : 0.3f;
float LookingIntensity = Vector3.Dot(transform.forward, (LookingPosition - transform.position).normalized);
LookAtIK(LookingPosition, LookingIntensity * LookWeightIK, BodyWeight, 0.6f);
}
}
然后去查这个RightHandToRespectiveIKPosition(),里面就是设置IK的API:
public void RightHandToRespectiveIKPosition(float IKWeight, float ElbowAdjustWeight = 0)
{
if (IKWeight == 0) return;
anim.SetIKRotationWeight(AvatarIKGoal.RightHand, IKWeight);
anim.SetIKPositionWeight(AvatarIKGoal.RightHand, IKWeight);
anim.SetIKPosition(AvatarIKGoal.RightHand, IKPositionRightHand.position);
anim.SetIKRotation(AvatarIKGoal.RightHand, IKPositionRightHand.rotation);
if (ElbowAdjustWeight == 0) return;
anim.SetIKHintPositionWeight(AvatarIKHint.RightElbow, ElbowAdjustWeight);
Vector3 hintPos = PivotItemRotation.transform.position + PivotItemRotation.transform.right * 2 + PivotItemRotation.transform.forward * 1 - PivotItemRotation.transform.up * 3f;
anim.SetIKHintPosition(AvatarIKHint.RightElbow, hintPos);
}
然后去看这个IKPositionRightHand是哪来的。在Awake()里创建了左右手IK Target 和IK Position,IK Position应该是当前帧IK的位置,Target则是它要去到的位置,这是为了做平滑运动。
// Generate Inverse Kinematics Transforms
LeftHandIKPositionTarget = CreateEmptyTransform("Left Hand Target", transform.position, transform.rotation, transform, false);
RightHandIKPositionTarget = CreateEmptyTransform("Right Hand Target", transform.position, transform.rotation, transform, false);
IKPositionLeftHand = CreateEmptyTransform("Left Hand IK Position", transform.position, transform.rotation, transform, true);
IKPositionRightHand = CreateEmptyTransform("Right Hand IK Position", transform.position, transform.rotation, transform, true);
创建IK Posotion时还把它们在Hierarchy隐藏了:
再去看哪里设置IK Target,在SmoothRightHandPosition(),从上面那个各类武器IK的管理脚本WeaponAimRotationCenter里取出这类武器的IK位置旋转:
//Get target transformations
Quaternion rightHandRotation = WeaponHoldingPositions.WeaponPositionTransform[HoldableItemInUseRightHand.ItemWieldPositionID].rotation;
Vector3 rightHandPosition = WeaponHoldingPositions.WeaponPositionTransform[HoldableItemInUseRightHand.ItemWieldPositionID].position;
//Set Right Hand IK Target Position
SetRightHandIKPosition(rightHandPosition, rightHandRotation);
设置IK Target位置旋转还封装了一层,纯吃饱了撑的。
public void SetRightHandIKPosition(Vector3 Position, Quaternion Rotation){
RightHandIKPositionTarget.position = Position;
RightHandIKPositionTarget.rotation = Rotation;
}
然后看SmoothRightHandPosition()在哪里调用了,在WieldingIKWeightController()里调用了:
private void WieldingIKWeightController()
{
//Disable FireMode IK when isn't in FireMode
if (FiringMode == false) FiringModeIK = false;
// Hands IK
if (IsItemEquiped)
{
SmoothLeftHandPosition(25);
SmoothRightHandPosition(25);
WieldingIKWeightController()在Update()里调用了:
void Update()
{
if (JUPauseGame.IsPaused) return;
FootPlacementIKController();
GroundCheck();
HealthCheck();
SetAnimatorParameters();
SetupDefaultLayersWeights();
if (IsDead) return;
DrivingCheck();
WallAHeadCheck();
if (DisableAllMove == false)
{
ControllerInputs();
StepCorrectionCalculation();
Rotate(HorizontalX, VerticalY);
RefreshItemAimRotationPivot();
WieldingIKWeightController();
WeaponOrientator();
}
else
{
LegsLayerWeight = Mathf.Lerp(LegsLayerWeight, 0, 8 * Time.deltaTime);
}
Events.UpdateRuntimeEventsCallbacks(this);
}
因为Weapon Aim Rotation Center跟随人物根对象,所以瞄准移动时能看见人物身体上下晃动,但是枪保持稳定。尘白禁区的人物移动也有这个现象,所以很可能也是这种架构,即通过绑定在人物根对象的IK目标对象把双手摆在合适的位置,瞄准移动时枪异常的稳定。
武器绑定
枪绑定在右手上。
枪的位置旋转是不规则的数据,且每把枪不一样……那这些数据要怎么记录?
答案是:所有枪都在手上挂一份,身上挂一份,使用的时候把手上的枪激活,身上的枪隐藏。只存在枪的激活和隐藏,不需要在手、后背之间转移。
相机和人物的关系
二者没有直接的父子关系。
ThirdPerson Camera Controller对象通过脚本固定在人物身上,旋转和世界一致。
Camera Pivot记录了相机的方向和仰角。Main Camera固定在Camera Pivot后上方。
移动
模板提供了多种移动方案,包括rigidbody.velocity、transform.Translate、动画根运动。
首先它定义了一个bool RootMotion。
在OnAnimatorMove()里执行ApplyRootMotionOnLocomotion()。
private void OnAnimatorMove()
{
ApplyRootMotionOnLocomotion();
}
这个函数只有RootMotion为真时才执行。但是我看到RootMotion一直为假,看来并没有用OnAnimatorMove()。那么怎么找到让人物移动的代码?
protected virtual void ApplyRootMotionOnLocomotion()
{
if (RootMotion && IsGrounded == true && IsJumping == false && !FiringMode && !IsDriving)
{
if (Ragdoller != null) { if (Ragdoller.State != AdvancedRagdollController.RagdollState.Animated) return; }
anim.updateMode = AnimatorUpdateMode.AnimatePhysics;
RootMotionDeltaPosition = anim.deltaPosition * Time.fixedDeltaTime;
RootMotionDeltaPosition.y = 0;
///_______________________________________________________________________________________________________________________________________________________
// >> NOTE: |
/// When decreasing the Time.timeScale, the Animator does not return the delta position correctly, preventing the character from moving in slow motion |
/// If Time Scale is different from 1, instead of rootmotion, normal motion without Root Motion base will be used so that it keeps moving in slow motion.|
///_______________________________________________________________________________________________________________________________________________________|
if (Time.timeScale == 1)
{
rb.velocity = RootMotionDeltaPosition * 5000 * RootMotionSpeed + Vector3.up * rb.velocity.y;
}
else
{
if (CurvedMovement)
{
rb.velocity = transform.forward * VelocityMultiplier * Speed + Vector3.up * rb.velocity.y;
}
else
{
rb.velocity = DirectionTransform.forward * VelocityMultiplier * Speed + Vector3.up * rb.velocity.y;
}
}
if (RootMotionRotation)
{
transform.Rotate(0, anim.deltaRotation.y * 160, 0);
}
}
}
对RootMotion寻找所有引用:
找到Movement(),它在FixedUpdate()里被调用。把它注释,人物不会再移动了,说明移动的代码在这里面。再看到里面调用了两个函数:
// Default Free Locomotion
DoFreeMovement(FiringMode);
//Default Fire Mode Locomotion
DoFireModeMovement(FiringMode);
然后把DoFreeMovement()注释,人物不瞄准时不能移动了,瞄准时可以移动。然后在这里打印FiringMode,发现没瞄准时打印false,瞄准时打印true( 注意如果一个脚本在场景里有多个实例,想研究这个脚本前先只留下一个,其他的全部失活 )。而且这两个函数里有渐变设置瞄准层权重的代码:
IsArmedWeight = Mathf.Lerp(IsArmedWeight, 0, 3 * Time.deltaTime);
再看DoFreeMovement(),里面有
if (IsGrounded && CanMove && !RootMotion)
{
if (CurvedMovement == true)
{
MoveForward(VelocityMultiplier);
}
else
{
Move(DirectionTransform, VelocityMultiplier);
}
}
打印发现一直在执行MoveForward()。 移动方法里根据SetRigidbodyVelocity是否为true,都有使用刚体velocity还是transform.Translate()移动两种方案。能感觉到使用刚体velocity移动时会受到墙的摩擦力。但是这个刚体运动经过处理,能上楼梯。
public virtual void Move(Vector3 Movement, float SpeedMultiplier)
{
if (SetRigidbodyVelocity)
{
rb.velocity = Movement * SpeedMultiplier * Speed;
}
else
{
transform.Translate(Movement * SpeedMultiplier * Speed * Time.deltaTime, Space.World);
}
}
定义了一个对象DirectionTransform。经过研究这个对象用来指向当前人物将要转向的方向。人物没有瞄准时移动是一边移动一边转向目标方向。不过好像也没有必要添加这个对象,使用相机的正前方就可以。
[HideInInspector] public Transform DirectionTransform;
自由观察移动和瞄准移动
自由观察对应的前进+转弯移动模式放在Base Layer。没有瞄准时Legs Motion Layer权重为0。
背后锁定对应的8方向移动放在Legs Motion Layer。瞄准时Legs Motion Layer权重为1。
CurvedMovement的作用
在自由观察移动时,如果相机看向人物后方,按W时,true是向人物的正前方移动,false是向相机的前方移动,也就是人物做前进动画,倒着移动。
但因为人物很快就转向相机前方了,两个情况效果区别不大。
上楼梯
应该是在StepCorrectionMovement()函数里处理的。
跳跃
首先这个模板人物使用了刚体+碰撞体。
在JUCharacterController脚本里的ControllerInputs()函数里:
//Jump
if (JUInput.GetButton(JUInput.Buttons.JumpButton) && IsJumping == false)
{
_Jump();
}
//New Jump Delay
_NewJumpDelay(0.2f, DecreaseSpeedOnJump);
_Jump()函数是这样的:如果人物不满跳跃的条件(不在地上、在跳跃、在滚、在开车……)就GetUp(),GetUp就是如果是趴着,就蹲,如果蹲着,就站。然后又通过名字判断状态机是否处于4种状态,分别是爬行不拿枪移动、正在趴下、爬行拿枪移动、蹲起,如果是就不跳。(我刚开始写人物动作控制时也曾通过判断当前状态名称判断能不能做某个动作,后来发现可以让动画关键帧设置一个bool字段,判断这个bool知道能不能做某个动作)然后是一些状态字段切换。然后是跳跃的核心代码rigidbody.AddForce()。然后下面那一块,SetRigidbodyVelocity==false里面是移动中跳跃时保持水平速度。如果SetRigidbodyVelocity==true,移动本身就使用刚体速度,就不用另外处理。
public virtual void _Jump()
{
if (IsGrounded == false || IsJumping == true || IsRolling == true || IsDriving == true || CanJump == false || IsProne || IsRagdolled)
{
_GetUp();
return;
}
if (anim.GetCurrentAnimatorStateInfo(0).IsName("Prone Free Locomotion BlendTree") ||
anim.GetCurrentAnimatorStateInfo(0).IsName("CrouchToProne") ||
anim.GetCurrentAnimatorStateInfo(0).IsName("Prone FireMode BlendTree") ||
anim.GetCurrentAnimatorStateInfo(0).IsName("Prone To Crouch")) return;
//Change States
IsGrounded = false;
IsJumping = true;
CanJump = false;
IsCrouched = false;
//Add Force
rb.AddForce(transform.up * 200 * JumpForce, ForceMode.Impulse);
if (SetRigidbodyVelocity == false)
{
rb.AddForce(DirectionTransform.forward * LastVelMult * rb.mass * Speed, ForceMode.Impulse);
VelocityMultiplier = 0;
}
//Disable IsJumping state in 0.3s
Invoke(nameof(_disablejump), 0.3f);
}
跳跃中的移动
跳跃中的移动由两部分组成,一个是刚体的速度,取决于跳跃瞬间的速度;一个是In Air Control Force,是跳跃中可控制的移动。
人物控制器有一个In Air Control Force字段,在脚本里叫
public float AirInfluenceControll = 0.5f;
控制人物在空中的移动速度。
然后注意到InAirMovementControl()函数,看起来是处理跳跃中移动的。在空中时打印刚体的速度,移动中跳跃时刚体水平速度不为0,说明是通过刚体的惯性实现跳跃中移动。但是没勾选SetRigidbodyVelocity时没有用刚体velocity移动(打印刚体velocity可以证明)
public void InAirMovementControl(bool JumpInert = true)
{
if (IsGrounded)
{
if (JumpInert)
{
LastX = HorizontalX;
LastY = VerticalY;
LastVelMult = VelocityMultiplier;
CanMove = true;
}
}
else
{
transform.Translate(0, -1f * Time.deltaTime, 0);
//if (SetRigidbodyVelocity)
//{
// if (IsMoving) rb.AddForce(DirectionTransform.forward * AirInfluenceControll * 10, ForceMode.Force);
//}
//else
//{
if (IsMoving)
{
transform.Translate(DirectionTransform.forward * AirInfluenceControll / 2 * Time.deltaTime, Space.World);
}
//}
}
}
从输入到执行
这个模板使用了新输入系统InputSystem,但是没用PlayerInput组件,而是直接用.inputactions生成的脚本。
各种动作bool
在JUInputManager里,定义了所有按键按下、按住、抬起的bool:
[HideInInspector]
public bool PressedShooting, PressedAiming, PressedReload, PressedRun, PressedJump, PressedPunch,
PressedCrouch, PressedProne, PressedRoll, PressedPickup, PressedInteract, PressedNextItem, PressedPreviousItem;
//OnDown
[HideInInspector]
public bool PressedShootingDown, PressedAimingDown, PressedReloadDown, PressedRunDown, PressedJumpDown, PressedPunchDown,
PressedCrouchDown, PressedProneDown, PressedRollDown, PressedPickupDown, PressedInteractDown, PressedNextItemDown, PressedPreviousItemDown, PressedOpenInventoryDown;
//OnUp
[HideInInspector]
public bool PressedShootingUp, PressedAimingUp, PressedReloadUp, PressedRunUp, PressedJumpUp, PressedPunchUp,
PressedCrouchUp, PressedProneUp, PressedRollUp, PressedPickupUp, PressedInteractUp, PressedNextItemUp, PressedPreviousItemUp;
在Update()里不停检测哪些按钮被按下、按住:
protected virtual void UpdateGetButtonDown()
{
PressedJumpDown = InputActions.Player.Jump.triggered;
PressedRunDown = InputActions.Player.Run.triggered;
PressedPunchDown = InputActions.Player.Punch.triggered;
PressedRollDown = InputActions.Player.Roll.triggered;
PressedProneDown = InputActions.Player.Prone.triggered;
PressedCrouchDown = InputActions.Player.Crouch.triggered;
PressedShootingDown = InputActions.Player.Fire.triggered;
PressedAimingDown = InputActions.Player.Aim.triggered;
PressedReloadDown = InputActions.Player.Reload.triggered;
PressedPickupDown = InputActions.Player.Pickup.triggered;
PressedInteractDown = InputActions.Player.Interact.triggered;
PressedNextItemDown = InputActions.Player.Next.triggered;
PressedPreviousItemDown = InputActions.Player.Previous.triggered;
PressedOpenInventoryDown = InputActions.Player.OpenInventory.triggered;
protected virtual void UpdateGetButton()
{
PressedJump = InputActions.Player.Jump.ReadValue<float>() == 1;
PressedRun = InputActions.Player.Run.ReadValue<float>() == 1;
PressedPunch = InputActions.Player.Punch.ReadValue<float>() == 1;
PressedRoll = InputActions.Player.Roll.ReadValue<float>() == 1;
PressedProne = InputActions.Player.Prone.ReadValue<float>() == 1;
PressedCrouch = InputActions.Player.Crouch.ReadValue<float>() == 1;
PressedShooting = InputActions.Player.Fire.ReadValue<float>() == 1;
PressedAiming = InputActions.Player.Aim.ReadValue<float>() == 1;
PressedReload = InputActions.Player.Reload.ReadValue<float>() == 1;
PressedPickup = InputActions.Player.Pickup.ReadValue<float>() == 1;
PressedInteract = InputActions.Player.Interact.ReadValue<float>() == 1;
PressedNextItem = InputActions.Player.Next.ReadValue<float>() == 1;
PressedPreviousItem = InputActions.Player.Previous.ReadValue<float>() == 1;
然后定义了一个叫Buttons的枚举:
public enum Buttons
{
ShotButton, AimingButton, JumpButton, RunButton, PunchButton,
RollButton, CrouchButton, ProneButton, ReloadButton,
PickupButton, EnterVehicleButton, PreviousWeaponButton, NextWeaponButton, OpenInventory
}
然后用GetButtonDown()返回指定按钮是否被按下、按住(直接用InputActions的API不行吗?):
public static bool GetButtonDown(Buttons Button)
{
GetJUInputInstance();
switch (Button)
{
case Buttons.ShotButton:
return JUInputInstance.PressedShootingDown;
case Buttons.AimingButton:
return JUInputInstance.PressedAimingDown;
case Buttons.JumpButton:
return JUInputInstance.PressedJumpDown;
case Buttons.RunButton:
return JUInputInstance.PressedRunDown;
case Buttons.PunchButton:
return JUInputInstance.PressedPunchDown;
case Buttons.RollButton:
return JUInputInstance.PressedRollDown;
case Buttons.CrouchButton:
return JUInputInstance.PressedCrouchDown;
case Buttons.ProneButton:
return JUInputInstance.PressedProneDown;
case Buttons.ReloadButton:
return JUInputInstance.PressedReloadDown;
case Buttons.PickupButton:
return JUInputInstance.PressedPickupDown;
case Buttons.EnterVehicleButton:
return JUInputInstance.PressedInteractDown;
case Buttons.PreviousWeaponButton:
return JUInputInstance.PressedPreviousItemDown;
case Buttons.NextWeaponButton:
return JUInputInstance.PressedNextItemDown;
case Buttons.OpenInventory:
return JUInputInstance.PressedOpenInventoryDown;
default:
return false;
}
}
public static bool GetButton(Buttons Button)
{
GetJUInputInstance();
switch (Button)
{
case Buttons.ShotButton:
return JUInputInstance.PressedShooting;
case Buttons.AimingButton:
return JUInputInstance.PressedAiming;
case Buttons.JumpButton:
return JUInputInstance.PressedJump;
case Buttons.RunButton:
return JUInputInstance.PressedRun;
case Buttons.PunchButton:
return JUInputInstance.PressedPunch;
case Buttons.RollButton:
return JUInputInstance.PressedRoll;
case Buttons.CrouchButton:
return JUInputInstance.PressedCrouch;
case Buttons.ProneButton:
return JUInputInstance.PressedProne;
case Buttons.ReloadButton:
return JUInputInstance.PressedReload;
case Buttons.PickupButton:
return JUInputInstance.PressedPickup;
case Buttons.EnterVehicleButton:
return JUInputInstance.PressedInteract;
case Buttons.PreviousWeaponButton:
return JUInputInstance.PressedPreviousItem;
case Buttons.NextWeaponButton:
return JUInputInstance.PressedNextItem;
default:
return false;
}
}
好的现在我们知道他的流程是InputActions.Player.XXX.triggered或InputActions.Player.XXX.ReadValue
移动和旋转float
定义:
//Move and Rotate Axis
[HideInInspector] public float MoveHorizontal;
[HideInInspector] public float MoveVertical;
[HideInInspector] public float RotateHorizontal;
[HideInInspector] public float RotateVertical;
赋值:在UpdateAxis()里,在Update()里执行。
MoveHorizontal = InputActions.Player.Move.ReadValue<Vector2>().x;
MoveVertical = InputActions.Player.Move.ReadValue<Vector2>().y;
MoveHorizontal = Mathf.Clamp(MoveHorizontal, -1, 1);
MoveVertical = Mathf.Clamp(MoveVertical, -1, 1);
和上面bool一样,通过GetAxis()输入轴的枚举,返回轴值。在Unity输入系统外封装了一层。
在人物脚本里也定义了一组移动旋转float
//MOVEMENT VARIABLES
[HideInInspector] public float VelocityMultiplier;
protected float VerticalY;
protected float HorizontalX;
赋值:在ControllerInputs()里,在Update()里执行。
if (!BlockHorizontalInput) HorizontalX = JUInput.GetAxis(JUInput.Axis.MoveHorizontal);
if (!BlockVerticalInput) VerticalY = JUInput.GetAxis(JUInput.Axis.MoveVertical);
IsMoving会通过这两个float赋值:
IsMoving = (Mathf.Abs(VerticalY) != 0 || Mathf.Abs(HorizontalX) != 0);
背包系统
背包的脚本是人物身上的Inventory
物品的继承关系:
拾取物品
检测拾取物品的函数是CheckItemsAround(),在Update()里执行,使用Physics.OverlapSphere()。会得到多个碰撞体,但是他只检测第一个。
记录拥有的物品
人物身上绑定有所有人物可能拥有的物品,每个物品有一个unlocked字段,
碰撞体
模板支持站、蹲、趴,需要改变碰撞体高度和方向。但是他是用了一个脚本每一帧都在改变碰撞体大小。
总结
为什么这个模板的代码看起来是屎山,一个主要原因是它在Update()或FixUpdate()里无脑执行所有动作函数,然后用一大堆bool判断这个函数是应该执行还是应该跳过。
巨量的bool字段:有一些状态其实是互斥的,比如人物不可能既蹲又趴。