using System.Collections; using System.Collections.Generic; #if INVECTOR_PLAYABLESANIMATOR using InvectorCore.Playables; using InvectorCore; #endif using UnityEngine; using UnityEngine.Events; namespace Invector.vCharacterController.vActions { [vClassHeader("Ladder Action", iconName = "ladderIcon")] public class vLadderAction : vActionListener, vIAnimatorMoveReceiver { private const string ClimbLadderTag = "ClimbLadder"; private const string SlideDownLadderTag = "LadderSlideDown"; // PDM state tags marking which hand a ladder state leaves planted when it ends. private const string LadderFootLeftTag = "LadderFootLeft"; private const string LadderFootRightTag = "LadderFootRight"; private const string EnterLadderTopState = "EnterLadderTop"; private const string EnterLadderBottomState = "EnterLadderBottom"; private const string ExitLadderTopState = "ExitLadderTop"; private const string ExitLadderBottomState = "ExitLadderBottom"; private const string QuickExitLadderState = "QuickExitLadder"; private const string SlideDownLadderState = "Ladder_SlideDown"; private const string IdleLadderLeftState = "IdleLadder_Left"; private const string IdleLadderRightState = "IdleLadder_Right"; private const float EnterAnimationTransitionDuration = 0.25f; private const float ExitAnimationTransitionDuration = 0.1f; private const float SlideDownTransitionDuration = 0.2f; private const float ClimbInputThreshold = 0.05f; private const float TriggerForwardDistanceThreshold = 0.8f; private const float ExitResetNormalizedTime = 0.8f; // The quick exit drops the character into open air, so it hands the body back to physics as soon // as its clip stops owning Y - matched to the row's root-motion Y range and LockMovement tag // window. The triggered exits end standing on ground and keep root motion to the end of the clip, // so they stay on the later shared gate above. private const float QuickExitResetNormalizedTime = 0.6f; private const float LadderInputSmoothingDuration = 0.1f; private const float AnimatorInputDampTime = 0.1f; private const float StaminaRecoveryDelay = 0.25f; #region public variables #if INVECTOR_PLAYABLESANIMATOR [vEditorToolbar("PDM", overrideChildOrder: true, order = 0)] [Header("Playables Animator")] [Tooltip("PDM that contains only the ladder states. The PDM active before entering the ladder is restored on exit.")] public PlayablesDecisionMaker ladderPDM; #endif [vEditorToolbar("Settings", overrideChildOrder: true, order = 0)] [Tooltip("Tag of the object you want to access")] public string actionTag = "LadderTrigger"; [Tooltip("Speed multiplier for the climb ladder animations")] public float climbSpeed = 1.5f; [Tooltip("Speed multiplier for the climb ladder animations when the fastClimbInput is pressed")] public float fastClimbSpeed = 3f; [Tooltip("How much Stamina will be consumed when climbing faster")] public float fastClimbStamina = 30f; [Tooltip("Input to use the ladder going up or down")] public GenericInput verticalInput = new GenericInput("Vertical", "LeftAnalogVertical", "Vertical"); [Tooltip("Input to enter the ladder")] public GenericInput enterInput = new GenericInput("E", "A", "A"); [Tooltip("Input to exit the ladder")] public GenericInput exitInput = new GenericInput("Space", "B", "B"); [Tooltip("Input to climb faster")] public GenericInput fastClimbInput = new GenericInput("LeftShift", "LeftStickClick", "LeftStickClick"); [Tooltip("Input to climb faster")] public GenericInput slideDownInput = new GenericInput("Q", "X", "X"); [vEditorToolbar("Events")] public UnityEvent OnEnterLadder; public UnityEvent OnExitLadder; public UnityEvent OnEnterTriggerLadder; public UnityEvent OnExitTriggerLadder; [vEditorToolbar("Debug")] public bool debugMode; [vReadOnly(false)] [SerializeField] protected vTriggerLadderAction targetLadderAction; [vReadOnly(false)] [SerializeField] protected vTriggerLadderAction currentLadderAction; //[vReadOnly(false)] //[SerializeField] protected List actionTriggers = new List(); [vReadOnly(false)] [SerializeField] protected float _speed; protected virtual float speed { get { return _speed; } set { _speed = value; } } [vReadOnly(false)] [SerializeField] protected float currentClimbSpeed; [vReadOnly(false)] [SerializeField] protected bool _isUsingLadder; #if INVECTOR_PLAYABLESANIMATOR [ExposeCondition] #endif public virtual bool isUsingLadder { get { return _isUsingLadder; } set { _isUsingLadder = value; } } #if INVECTOR_PLAYABLESANIMATOR [vReadOnly(false)] [SerializeField] protected float ladderInputSmooth; /// Input used by the ladder PDM rows. [ExposeCondition] public virtual float LadderInputVertical { get { return ladderInputSmooth; } } /// Clip retime factor for the UsingLadder PDM rows. [ExposeCondition] public virtual float LadderClimbSpeed { get { return currentClimbSpeed; } } #endif public enum vLadderSide { Left, Right } [vReadOnly(false)] [SerializeField] protected vLadderSide ladderSide = vLadderSide.Left; /// Which side the character is planted on; the PDM condition for the next state. #if INVECTOR_PLAYABLESANIMATOR [ExposeCondition] #endif public virtual vLadderSide LadderSide { get { return ladderSide; } } [vReadOnly(false)] [SerializeField] protected bool enterLadderStarted; [vReadOnly(false)] [SerializeField] protected bool inEnterLadderAnimation; [vReadOnly(false)] [SerializeField] protected bool inExitingLadderAnimation; [vReadOnly(false)] [SerializeField] protected bool triggerEnterOnce; /// /// Root XZ captured on the first frame of the enter match, so the outgoing run's root motion /// stops deciding where the body goes while the enter curve is still near zero. /// protected Vector2 matchOriginXZ; protected bool hasMatchOriginXZ; /// When the enter match started, so the hold can be bounded to the cross-fade. protected float matchOriginTime; [vReadOnly(false)] [SerializeField] protected bool triggerExitOnce; #endregion protected vThirdPersonInput tpInput; #if INVECTOR_PLAYABLESANIMATOR protected PlayablesAnimator playablesAnimator; protected PlayablesDecisionMaker previousPDM; protected bool ladderPDMIsActive; protected Coroutine restorePreviousPDMCoroutine; protected virtual bool UsingPlayables => playablesAnimator && playablesAnimator.isActiveAndEnabled; protected virtual bool CanUseLadderPDM => UsingPlayables && ladderPDM; #endif protected bool isAligningMidAir; protected override void SetUpListener() { actionEnter = false; actionStay = true; actionExit = true; } protected override void Start() { base.Start(); #if INVECTOR_PLAYABLESANIMATOR playablesAnimator = GetComponent(); if (playablesAnimator && !playablesAnimator.isActiveAndEnabled) { playablesAnimator = null; } #endif tpInput = GetComponent(); if (tpInput) { tpInput.onUpdate -= UpdateLadderBehavior; tpInput.onUpdate += UpdateLadderBehavior; #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { playablesAnimator.OnPlayablesStateEnter -= HandleLadderStateEnter; playablesAnimator.OnPlayablesStateEnter += HandleLadderStateEnter; } #endif } } // Detaches callbacks registered in Start, so a runtime-spawned character doesn't leak this action. protected virtual void OnDestroy() { if (tpInput) { tpInput.onUpdate -= UpdateLadderBehavior; } #if INVECTOR_PLAYABLESANIMATOR if (playablesAnimator) { playablesAnimator.OnPlayablesStateEnter -= HandleLadderStateEnter; } #endif } // Routed by vAnimatorMoveSender. Skipped while Playables drives so the climb doesn't tick twice. public virtual void OnAnimatorMoveEvent() { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { return; } #endif UsingLadder(); } #if INVECTOR_PLAYABLESANIMATOR // PlayablesAnimator's counterpart to OnAnimatorMoveEvent. protected virtual void OnPlayablesMove() { UsingLadder(); } #endif protected virtual void UpdateLadderBehavior() { AutoEnterLadder(); EnterLadderInput(); ExitLadderInput(); } protected virtual void EnterLadderInput() { if (targetLadderAction == null || tpInput.cc.customAction || tpInput.cc.isJumping || !tpInput.cc.isGrounded || tpInput.cc.isRolling) { return; } if (enterInput.GetButtonDown() && !enterLadderStarted && !isUsingLadder && !targetLadderAction.autoAction) { TriggerEnterLadder(); } } protected virtual bool IsUsingLadder() { return tpInput.cc.IsAnimatorTag(ClimbLadderTag); } #if INVECTOR_PLAYABLESANIMATOR // Reads which hand the committed state ends on, not whatever ladderSide already held, so a // forced idle (TriggerMidAirEnterLadder, the enter states) resyncs instead of drifting. Down // states reuse the Up clips at negative speed, so their planted side is tagged rather than // derived from the name. protected virtual void HandleLadderStateEnter(in PlayablesStateContext context) { if (context.Layer == 0 && playablesAnimator.HasAnyTag(LadderFootLeftTag, LadderFootRightTag)) { ladderSide = playablesAnimator.HasTag(LadderFootLeftTag) ? vLadderSide.Left : vLadderSide.Right; } } #endif // Animator-only: its climb loop is one state, so a transition means "still entering". The PDM // alternates Left/Right every climb cycle and would report a transition almost every frame, // blocking exit forever. protected virtual bool IsInExitBlockingTransition() { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { return false; } #endif return tpInput.cc.animator.IsInTransition(0); } protected virtual bool IsPlayingLadderState(string stateName) { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { return playablesAnimator.IsPlayingState(stateName); } #endif if (tpInput.cc.baseLayerInfo.IsName(stateName)) { return true; } // During CrossFade the CURRENT state is still the one being left, so gating on it stays // shut for the whole blend and the match starts late, covering the full angle in a few // frames (the snap). The destination state is what's actually playing in. var animator = tpInput.cc.animator; return animator.IsInTransition(0) && animator.GetNextAnimatorStateInfo(0).IsName(stateName); } /// Returns the active base-layer playback progress for match-target curve evaluation. protected virtual float GetLadderNormalizedTime() { float normalizedTime; #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { normalizedTime = playablesAnimator.GetNormalizedTime(0); } else #endif { // GetNextAnimatorStateInfo(0).normalizedTime is NOT the destination clip's progress // during a blend - it reports near 1 and snaps the match to its final pose. Scale the // transition's own normalizedTime by blend duration over clip length instead. var animator = tpInput.cc.animator; if (animator.IsInTransition(0)) { var transition = animator.GetAnimatorTransitionInfo(0); var next = animator.GetNextAnimatorStateInfo(0); float clipLength = next.length > 0.0001f ? next.length : 1f; normalizedTime = Mathf.Clamp01(transition.normalizedTime) * (transition.duration / clipLength); } else { normalizedTime = tpInput.cc.baseLayerInfo.normalizedTime; } } return normalizedTime; } protected virtual void SetLadderAnimationSpeed(float value) { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { return; } #endif tpInput.cc.animator.speed = value; } // PlayablesAnimator resolves its own transitions; Animator uses the supplied blend duration. protected virtual void PlayAnimation(string stateName, float transitionDuration) { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { playablesAnimator.PlayState(stateName); return; } #endif tpInput.cc.animator.CrossFadeInFixedTime(stateName, transitionDuration); } protected virtual bool InEnterLadderAnimation() { return IsPlayingLadderState(EnterLadderTopState) || IsPlayingLadderState(EnterLadderBottomState); } // Separate from InEnterLadderAnimation: only a transition heading AWAY from the enter states // should block the match, not the ENTER blend itself - otherwise the match starts late and snaps. protected virtual bool InEnterLadderAnimationMatchTarget() { if (!InEnterLadderAnimation()) { return false; } return !IsLeavingEnterLadderAnimation(); } // True while the base layer is blending OUT of an enter-ladder state, as opposed to into one. protected virtual bool IsLeavingEnterLadderAnimation() { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { return false; } #endif var animator = tpInput.cc.animator; if (!animator.IsInTransition(0)) { return false; } var next = animator.GetNextAnimatorStateInfo(0); return !next.IsName(EnterLadderTopState) && !next.IsName(EnterLadderBottomState); } // Animator only: Playables rows don't expose Animator tags, so it stays false for that backend. protected virtual bool InSlideDownAnimation() { return tpInput.cc.IsAnimatorTag(SlideDownLadderTag); } protected virtual bool InExitLadderAnimation() { return IsPlayingLadderState(ExitLadderTopState) || IsPlayingLadderState(ExitLadderBottomState) || IsPlayingLadderState(QuickExitLadderState); } protected virtual void ExitLadderInput() { if (!isUsingLadder || InEnterLadderAnimation()) { return; } if (targetLadderAction == null) { HandleExitWithoutTrigger(); return; } currentLadderAction = targetLadderAction; var exitState = targetLadderAction.exitAnimation; if (exitState == ExitLadderBottomState) { TryExitAtBottom(exitState); } else if (exitState == ExitLadderTopState && IsUsingLadder()) { TryExitAtTop(exitState); } } private void HandleExitWithoutTrigger() { if (!IsUsingLadder()) { return; } if (slideDownInput.GetButtonDown() && !inExitingLadderAnimation) { PlayAnimation(SlideDownLadderState, SlideDownTransitionDuration); } if (!exitInput.GetButtonDown()) { return; } LogLadderAction("Quick Exit"); SetLadderAnimationSpeed(1f); PlayAnimation(QuickExitLadderState, ExitAnimationTransitionDuration); } private void TryExitAtBottom(string exitState) { var shouldExit = !triggerExitOnce && (exitInput.GetButtonDown() || speed <= -ClimbInputThreshold || InSlideDownAnimation()); if (!shouldExit) { return; } LogLadderAction("Exit Bottom"); triggerExitOnce = true; PlayAnimation(exitState, ExitAnimationTransitionDuration); } private void TryExitAtTop(string exitState) { if (triggerExitOnce || speed < ClimbInputThreshold || IsInExitBlockingTransition()) { return; } LogLadderAction("Exit Top"); triggerExitOnce = true; PlayAnimation(exitState, ExitAnimationTransitionDuration); } protected virtual void AutoEnterLadder() { if (targetLadderAction == null || !targetLadderAction.autoAction) { return; } if (tpInput.cc.customAction || isUsingLadder || IsInExitBlockingTransition() || tpInput.cc.isRolling) { return; } if (tpInput.cc.input != Vector3.zero) { var activeCamera = Camera.main; if (!activeCamera) { return; } var inputDir = activeCamera.transform.TransformDirection(new Vector3(tpInput.cc.input.x, 0f, tpInput.cc.input.z)); inputDir.y = 0f; var dist = Vector3.Distance(inputDir.normalized, targetLadderAction.transform.forward); if (dist < TriggerForwardDistanceThreshold) { TriggerEnterLadder(); } } } public virtual void TriggerMidAirEnterLadder(vTriggerLadderMiddle ladderMiddle) { if (tpInput.cc.isGrounded || isAligningMidAir) { return; } isAligningMidAir = true; var targetPosition = ladderMiddle.refTarget.position; var foundClosestPoint = false; var closestDistance = Vector3.Distance(targetPosition, transform.position); var stepCount = (int)(ladderMiddle._collider.size.y / ladderMiddle.stepHeight); // Walk up the rungs and keep the closest one; stop once distance starts increasing again. for (var i = 0; i < stepCount; i++) { var candidatePosition = ladderMiddle.refTarget.position + ladderMiddle.transform.up * ladderMiddle.stepHeight * i; var candidateDistance = Vector3.Distance(candidatePosition, transform.position); if (candidateDistance <= closestDistance) { targetPosition = candidatePosition; closestDistance = candidateDistance; foundClosestPoint = true; } else if (foundClosestPoint) { break; } } tpInput.cc.DisableGravityAndCollision(); tpInput.cc.isCrouching = false; tpInput.cc.ControlCapsuleHeight(); tpInput.UpdateCameraStates(); tpInput.cc.UpdateAnimator(); triggerEnterOnce = true; enterLadderStarted = true; tpInput.cc.animator.SetInteger(vAnimatorParameters.ActionState, 1); // set actionState 1 to avoid falling transitions tpInput.cc.StopCharacter(); tpInput.SetLockAllInput(true); tpInput.cc.ResetInputAnimatorParameters(); #if INVECTOR_PLAYABLESANIMATOR SwitchToLadderPDM(); #endif var localPos = ladderMiddle.transform.InverseTransformPoint(transform.position); var idleState = localPos.x >= 0f ? IdleLadderLeftState : IdleLadderRightState; #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { // PDM has no ActionState guard against Falling, only IsUsingLadder - without this the // idle state loses the next evaluation to Falling before AlignToMiddleLadder sets it. isUsingLadder = true; } #endif PlayAnimation(idleState, EnterAnimationTransitionDuration); tpInput.cc.disableAnimations = true; StartCoroutine(AlignToMiddleLadder(targetPosition, ladderMiddle.refTarget.rotation)); } protected virtual IEnumerator AlignToMiddleLadder(Vector3 targetPosition, Quaternion targetRotation) { float time = 0f; Vector3 initialPosition = transform.position; Quaternion initialRotation = transform.rotation; while (time <= 1f) { transform.position = Vector3.Lerp(initialPosition, targetPosition, time); transform.rotation = Quaternion.Lerp(initialRotation, targetRotation, time); time += Time.fixedDeltaTime * 8f; yield return null; } // Pin the pose until the idle blend settles, so its root motion can't drag the character off // the ladder. Uses IsInExitBlockingTransition, not IsInLadderTransition - see its summary. do { transform.position = targetPosition; transform.rotation = targetRotation; yield return null; } while (IsInExitBlockingTransition()); yield return new WaitForEndOfFrame(); isUsingLadder = true; isAligningMidAir = false; } protected virtual void TriggerEnterLadder() { if (debugMode) { Debug.Log("Enter Ladder", this); } OnExitTriggerLadder?.Invoke(); if (targetLadderAction.targetCharacterParent) { transform.parent = targetLadderAction.targetCharacterParent; } tpInput.cc.isCrouching = false; tpInput.cc.ControlCapsuleHeight(); tpInput.UpdateCameraStates(); tpInput.cc.UpdateAnimator(); OnEnterLadder?.Invoke(); triggerEnterOnce = true; enterLadderStarted = true; tpInput.cc.animator.SetInteger(vAnimatorParameters.ActionState, 1); // set actionState 1 to avoid falling transitions tpInput.SetLockAllInput(true); tpInput.cc.ResetInputAnimatorParameters(); targetLadderAction.OnDoAction?.Invoke(); currentLadderAction = targetLadderAction; #if INVECTOR_PLAYABLESANIMATOR SwitchToLadderPDM(); #endif if (!string.IsNullOrEmpty(currentLadderAction.playAnimation)) { if (debugMode) { LogLadderAction("Trigger Animation"); } PlayAnimation(currentLadderAction.playAnimation, EnterAnimationTransitionDuration); isUsingLadder = true; tpInput.cc.disableAnimations = true; tpInput.cc.StopCharacter(); } } protected virtual void UsingLadder() { if (!isUsingLadder) { return; } // update the base layer to know what animations are being played tpInput.cc.AnimatorLayerControl(); tpInput.cc.ActionsControl(); // update camera movement tpInput.CameraInput(); // go up or down speed = verticalInput.GetAxis(); #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { ladderInputSmooth = InEnterLadderAnimation() ? 0f : Mathf.MoveTowards(ladderInputSmooth, speed, Time.deltaTime / LadderInputSmoothingDuration); } else #endif { tpInput.cc.animator.SetFloat(vAnimatorParameters.InputVertical, speed, AnimatorInputDampTime, Time.deltaTime); if (speed >= ClimbInputThreshold || speed <= -ClimbInputThreshold) { tpInput.cc.animator.speed = Mathf.Lerp(tpInput.cc.animator.speed, currentClimbSpeed, 2f * Time.deltaTime); } else { tpInput.cc.animator.speed = Mathf.Lerp(tpInput.cc.animator.speed, 1f, 2f * Time.deltaTime); } } // increase speed by input and consume stamina if (fastClimbInput.GetButton() && tpInput.cc.currentStamina > 0) { currentClimbSpeed = fastClimbSpeed; StaminaConsumption(); } else { currentClimbSpeed = climbSpeed; } // enter ladder behaviour var _inEnterLadderAnimation = InEnterLadderAnimationMatchTarget(); if (_inEnterLadderAnimation) { inEnterLadderAnimation = true; tpInput.cc.DisableGravityAndCollision(); // disable gravity & turn collision trigger // disable ingame hud if (currentLadderAction != null) { currentLadderAction.OnPlayerExit?.Invoke(); } float normalizedTime = GetLadderNormalizedTime(); if (currentLadderAction.useTriggerRotation && currentLadderAction.matchTarget != null) { if (debugMode) { LogLadderAction("Rotating to target"); } EvaluateToRotation(currentLadderAction.enterRotationCurve, currentLadderAction.matchTarget.transform.rotation, normalizedTime); } if (currentLadderAction.matchTarget != null) { if (transform.parent != currentLadderAction.targetCharacterParent) { transform.parent = currentLadderAction.targetCharacterParent; } if (debugMode) { LogLadderAction("Match target to enter"); } EvaluateToPosition(currentLadderAction.enterPositionXZCurve, currentLadderAction.enterPositionYCurve, currentLadderAction.matchTarget.position, normalizedTime); } } if (!_inEnterLadderAnimation && inEnterLadderAnimation) { enterLadderStarted = false; inEnterLadderAnimation = false; } TriggerExitLadder(); } protected virtual void TriggerExitLadder() { // exit ladder behaviour inExitingLadderAnimation = InExitLadderAnimation(); if (inExitingLadderAnimation) { SetLadderAnimationSpeed(1f); float normalizedTime = GetLadderNormalizedTime(); // A quick exit is taken mid-ladder, away from any trigger, so there is no // currentLadderAction and no authored match target - the clip's own root motion is // the whole movement. Only the trigger-driven exits match to a target. if (currentLadderAction != null) { if (currentLadderAction.exitMatchTarget != null && !IsPlayingLadderState(QuickExitLadderState)) { if (debugMode) { LogLadderAction("Match target to exit"); } EvaluateToPosition(currentLadderAction.exitPositionXZCurve, currentLadderAction.exitPositionYCurve, currentLadderAction.exitMatchTarget.position, normalizedTime, true); } var newRot = new Vector3(0, GetLadderRootRotation().eulerAngles.y, 0); EvaluateToRotation(currentLadderAction.exitRotationCurve, Quaternion.Euler(newRot), normalizedTime); } float resetTime = IsPlayingLadderState(QuickExitLadderState) ? QuickExitResetNormalizedTime : ExitResetNormalizedTime; if (normalizedTime >= resetTime) { // Hand the character back to physics exactly where the clip stops owning it: the // LockMovement tag and the row's root-motion Y range both end at this same // normalized time. Releasing earlier let gravity fight the clip's root motion for // the body - the jitter - and releasing at a fixed wall-clock delay had no // relation to where the clip actually was. ResetPlayerSettings(); // The climb ran kinematic, so heightReached is still pinned to the top of the // ladder. The fall that starts here is a new one, not a drop from that height - // without this it reads as a long fall and triggers fall damage, LandHigh or the // ragdoll. tpInput.cc.ResetFallState(); } } } protected virtual Vector3 GetLadderRootPosition() { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { return playablesAnimator.rootPosition; } #endif return tpInput.cc.animator.rootPosition; } // Equivalent of Animator.rootRotation: the pending pose after this frame's root motion, applied // by the script's own write to transform.rotation. protected virtual Quaternion GetLadderRootRotation() { #if INVECTOR_PLAYABLESANIMATOR if (UsingPlayables) { return playablesAnimator.rootRotation; } #endif return tpInput.cc.animator.rootRotation; } protected virtual void EvaluateToPosition(AnimationCurve XZ, AnimationCurve Y, Vector3 targetPosition, float normalizedTime, bool isExit = false) { Vector3 rootPosition = GetLadderRootPosition(); float evaluatedXZ = XZ.Evaluate(normalizedTime); float evaluatedY = Y.Evaluate(normalizedTime); // Hold the XZ the root started at, but ONLY for the length of the cross-fade, and only when // entering. During that window the state being left is still blended in and still feeding // its own root motion - a full-speed run when the player sprints at the ladder - which the // lerp below would otherwise carry straight into the wall the ladder is mounted on. // // The window is wall-clock, not curve-driven, because a curve reading ~0 means two // opposite things here: early in EnterLadderBottom it means "the match has not taken over // yet" (freeze is right), while in EnterLadderTop and in every exit it means "this stretch // belongs to root motion" - those animations travel 2.45m down and off the ladder under // their own steam, and freezing pins the character in place. Bounding the hold to the // cross-fade covers the contaminated frames without ever reaching that authored motion. // // Both axes are held, unlike vGenericAction: vTriggerLadderAction has no // useLocalX/useLocalZ, so the match always owns X and Z together. if (!isExit) { if (!hasMatchOriginXZ) { matchOriginXZ = new Vector2(rootPosition.x, rootPosition.z); matchOriginTime = Time.time; hasMatchOriginXZ = true; } if (Time.time - matchOriginTime < EnterAnimationTransitionDuration) { rootPosition.x = matchOriginXZ.x; rootPosition.z = matchOriginXZ.y; } } if (evaluatedXZ < 1f) { rootPosition.x = Mathf.Lerp(rootPosition.x, targetPosition.x, evaluatedXZ); rootPosition.z = Mathf.Lerp(rootPosition.z, targetPosition.z, evaluatedXZ); } if (evaluatedY < 1f) { rootPosition.y = Mathf.Lerp(rootPosition.y, targetPosition.y, evaluatedY); } transform.position = rootPosition; } protected virtual void EvaluateToRotation(AnimationCurve curve, Quaternion targetRotation, float normalizedTime) { Quaternion rootRotation = GetLadderRootRotation(); float evaluatedCurve = curve.Evaluate(normalizedTime); if (evaluatedCurve < 1) { rootRotation = Quaternion.Lerp(rootRotation, targetRotation, evaluatedCurve); } transform.rotation = rootRotation; } protected virtual void StaminaConsumption() { if (tpInput.cc.currentStamina <= 0) { return; } tpInput.cc.ReduceStamina(fastClimbStamina, true); // call the ReduceStamina method from the player tpInput.cc.currentStaminaRecoveryDelay = StaminaRecoveryDelay; } protected virtual void AddLadderTrigger(vTriggerLadderAction _ladderAction) { if (!_ladderAction) { return; } if (targetLadderAction != _ladderAction) { targetLadderAction = _ladderAction; LogLadderAction("Trigger stay"); } if (!actionTriggers.Contains(targetLadderAction)) { actionTriggers.Add(targetLadderAction); targetLadderAction.OnPlayerEnter?.Invoke(); } } protected virtual void RemoveLadderTrigger(vTriggerLadderAction _ladderAction) { if (_ladderAction == targetLadderAction) { targetLadderAction = null; } if (actionTriggers.Contains(_ladderAction)) { actionTriggers.Remove(_ladderAction); _ladderAction.OnPlayerExit?.Invoke(); } } protected virtual void CheckForTriggerAction(Collider other) { var _ladderAction = other.GetComponent(); if (!_ladderAction) { return; } var dist = Vector3.Distance(transform.forward, _ladderAction.transform.forward); if (isUsingLadder) { if (targetLadderAction != _ladderAction) { targetLadderAction = _ladderAction; if (!actionTriggers.Contains(targetLadderAction)) { actionTriggers.Add(targetLadderAction); } } } else if (!_ladderAction.activeFromForward || dist <= TriggerForwardDistanceThreshold) { AddLadderTrigger(_ladderAction); OnEnterTriggerLadder?.Invoke(); } else { RemoveLadderTrigger(_ladderAction); } } public virtual void ResetPlayerSettings() { if (debugMode) { Debug.Log("Reset Player Settings", this); } speed = 0f; #if INVECTOR_PLAYABLESANIMATOR ladderInputSmooth = 0f; #endif ladderSide = vLadderSide.Left; targetLadderAction = null; isUsingLadder = false; OnExitLadder?.Invoke(); triggerExitOnce = false; triggerEnterOnce = false; inEnterLadderAnimation = false; enterLadderStarted = false; hasMatchOriginXZ = false; tpInput.cc.animator.SetInteger(vAnimatorParameters.ActionState, 0); tpInput.cc.EnableGravityAndCollision(); tpInput.SetLockAllInput(false); tpInput.cc.StopCharacter(); tpInput.cc.disableAnimations = false; #if INVECTOR_PLAYABLESANIMATOR RestorePreviousPDMAfterGroundCheck(); #endif if (transform.parent != null) { transform.parent = null; } } #if INVECTOR_PLAYABLESANIMATOR /// Temporarily assigns the optional ladder-only PDM and remembers the current assignment. protected virtual void SwitchToLadderPDM() { if (!CanUseLadderPDM) { return; } if (restorePreviousPDMCoroutine != null) { StopCoroutine(restorePreviousPDMCoroutine); restorePreviousPDMCoroutine = null; } if (ladderPDMIsActive) { return; } previousPDM = playablesAnimator.ActivePDM; playablesAnimator.SetPDM(ladderPDM); ladderPDMIsActive = true; } /// /// Delays the base-PDM handoff until the controller has run its next fixed ground check after /// gravity and collision are restored. A quick exit in open air therefore still selects Falling. /// protected virtual void RestorePreviousPDMAfterGroundCheck() { if (!ladderPDMIsActive || restorePreviousPDMCoroutine != null) { return; } restorePreviousPDMCoroutine = StartCoroutine(RestorePreviousPDMOnNextFixedUpdate()); } protected virtual IEnumerator RestorePreviousPDMOnNextFixedUpdate() { yield return new WaitForFixedUpdate(); restorePreviousPDMCoroutine = null; RestorePreviousPDM(); } /// Restores the PDM that was active before this ladder interaction started. protected virtual void RestorePreviousPDM() { if (!ladderPDMIsActive) { return; } // Do not overwrite a PDM selected by another system while the ladder was active. if (UsingPlayables && playablesAnimator.ActivePDM == ladderPDM) { playablesAnimator.SetPDM(previousPDM); } previousPDM = null; ladderPDMIsActive = false; } #endif public override void OnActionStay(Collider other) { if (other.gameObject.CompareTag(actionTag)) { CheckForTriggerAction(other); } } public override void OnActionExit(Collider other) { if (other.gameObject.CompareTag(actionTag)) { var _ladderAction = other.GetComponent(); if (!_ladderAction) { return; } RemoveLadderTrigger(_ladderAction); if (debugMode) { var parentName = other.transform.parent ? other.transform.parent.name : ""; Debug.Log("Trigger Exit: " + other.name + "_" + parentName, other); } OnExitTriggerLadder?.Invoke(); } } private void LogLadderAction(string message) { if (!debugMode) { return; } var ladderAction = currentLadderAction ? currentLadderAction : targetLadderAction; var actionName = ladderAction ? ladderAction.name : ""; var parentName = ladderAction && ladderAction.transform.parent ? ladderAction.transform.parent.name : ""; Debug.Log(message + ": " + actionName + "_" + parentName, this); } } }