using UnityEngine; using UnityEngine.InputSystem; using Synty.AnimationBaseLocomotion.Samples.InputSystem; public class MeleeCombatLayerController : MonoBehaviour { [Header("References")] [Tooltip("The InputReader on your character - same one the player controller uses")] [SerializeField] private InputReader _inputReader; [Tooltip("The Animator on your character")] [SerializeField] private Animator _animator; [Tooltip("Your main camera's transform - used to know where you're looking")] [SerializeField] private Transform _cameraTransform; [Tooltip("The player's stats script for stamina usage")] [SerializeField] private PlayerStats _playerStats; [Header("Weapon Management")] [Tooltip("The active weapon used for damage and stats.")] [SerializeField] private Weapon _currentWeapon; [Header("Combo System")] [Tooltip("The UI Image (like a Chain Icon) that appears when the player is allowed to combo.")] [SerializeField] private UnityEngine.UI.Image _comboIndicatorUI; [SerializeField] private Sprite _comboStandbySprite; [SerializeField] private Sprite _comboMissedSprite; [SerializeField] private Color _comboStandbyColor = Color.white; [SerializeField] private Color _comboSuccessColor = Color.green; [SerializeField] private Color _comboMissedColor = Color.red; [Tooltip("The Transform socket where the weapon is held when equipped (e.g., Prop_R)")] [SerializeField] private Transform _equippedSocket; [Tooltip("The Transform socket where the weapon rests when put away (e.g., Spine_03 or a Back Socket)")] [SerializeField] private Transform _unequippedSocket; [Header("Layer Settings")] [Tooltip("The name of the Animator layer containing the melee override (e.g. 'Melee')")] [SerializeField] private string _meleeLayerName = "Melee"; [Tooltip("How fast the melee layer blends in and out when equipping. Higher = snappier.")] [SerializeField] private float _equipBlendSpeed = 5f; [Header("Combat Settings")] [Tooltip("Layers to consider as targets when attacking")] [SerializeField] private LayerMask _targetLayers; [Header("Input Actions")] [Tooltip("Input action for attacking")] [SerializeField] private InputAction _attackAction = new InputAction("Attack", binding: "/leftButton"); [Tooltip("Input action for blocking")] [SerializeField] private InputAction _blockAction = new InputAction("Block", binding: "/rightButton"); // Animation Hashes private readonly int _powerUpHash = Animator.StringToHash("PowerUp"); private readonly int _attackHash = Animator.StringToHash("Attack"); private readonly int _isBlockingHash = Animator.StringToHash("IsBlocking"); private readonly int _takeDamageHash = Animator.StringToHash("TakeDamage"); // Runtime state private bool _isEquipped; private bool _isBlocking; private bool _isPoweringUp; private float _equipWeight; private int _meleeLayerIndex; private Transform _spineBone; private void Start() { _meleeLayerIndex = _animator.GetLayerIndex(_meleeLayerName); _spineBone = _animator.GetBoneTransform(HumanBodyBones.Spine); // If the referenced weapon is a prefab asset (not in the scene), instantiate it if (_currentWeapon != null && !_currentWeapon.gameObject.scene.IsValid()) { _currentWeapon = Instantiate(_currentWeapon); } // Start with weapon in correct socket UpdateWeaponSocket(); // Try getting the main camera if not set if (_cameraTransform == null && Camera.main != null) { _cameraTransform = Camera.main.transform; } } private void OnEnable() { _attackAction.started += OnAttackStarted; _attackAction.canceled += OnAttackCanceled; _blockAction.started += OnBlockStarted; _blockAction.canceled += OnBlockCanceled; _attackAction.Enable(); _blockAction.Enable(); } private void OnDisable() { _attackAction.started -= OnAttackStarted; _attackAction.canceled -= OnAttackCanceled; _blockAction.started -= OnBlockStarted; _blockAction.canceled -= OnBlockCanceled; _attackAction.Disable(); _blockAction.Disable(); } private bool _isAttacking; public bool IsAttacking => _isAttacking; // --- Debug Tracking --- private float _lastAttackInputTime = 0f; private float _lastAttackAnimationEndTime = 0f; private bool _wasAttackingLastFrame = false; private void Update() { // We only want this layer to be active if we are actively attacking, blocking, or equipping. bool isLayerActive = false; _isAttacking = false; if (_animator != null) { // Check all layers for the Attack state for (int i = 0; i < _animator.layerCount; i++) { AnimatorStateInfo stateInfo = _animator.GetCurrentAnimatorStateInfo(i); if (stateInfo.IsTag("Attack") || stateInfo.IsName("Attack") || stateInfo.IsName("Attack1") || stateInfo.IsName("Attack2") || stateInfo.IsName("Attack3") || stateInfo.IsName("A_MOD_SWD_Attack_03")) { _isAttacking = true; break; } } // Track exactly when the attack animation finishes if (!_isAttacking && _wasAttackingLastFrame) { _lastAttackAnimationEndTime = Time.time; _comboSuccessfullyBuffered = false; _isComboWindowOpen = false; // Forcefully hide the UI just in case an animation was interrupted before its Close event fired! if (_comboIndicatorUI != null) { _comboIndicatorUI.gameObject.SetActive(false); } } _wasAttackingLastFrame = _isAttacking; if (_meleeLayerIndex != -1) { AnimatorStateInfo stateInfo = _animator.GetCurrentAnimatorStateInfo(_meleeLayerIndex); bool isPlayingCombatAnim = stateInfo.IsName("Equip") || stateInfo.IsName("Unequip"); // If attacking on ANY layer, or blocking/equipping, keep the combat state active if (_isPoweringUp || _isBlocking || isPlayingCombatAnim) { isLayerActive = true; } // If the attack is actually on the melee layer (old setup), keep weight 1 if (stateInfo.IsName("Attack")) { isLayerActive = true; } } } // Blend the melee layer weight based on if it's currently active float equipTarget = isLayerActive ? 1f : 0f; _equipWeight = Mathf.MoveTowards(_equipWeight, equipTarget, _equipBlendSpeed * Time.deltaTime); if (_meleeLayerIndex != -1) { _animator.SetLayerWeight(_meleeLayerIndex, _equipWeight); } // Send parameters to animator if (_animator != null) { _animator.SetBool(_isBlockingHash, _isBlocking); } } public bool IsEquipped => _isEquipped; public System.Collections.IEnumerator EquipWeapon() { if (_isEquipped) yield break; if (_currentWeapon == null) { Debug.LogWarning("Cannot equip! No melee weapon is assigned."); yield break; } _isEquipped = true; if (_meleeLayerIndex != -1) { _animator.Play("Equip", _meleeLayerIndex, 0f); } // Wait for the hand to reach the weapon on the back before snapping it to the hand yield return new WaitForSeconds(0.3f); UpdateWeaponSocket(); } public System.Collections.IEnumerator UnequipWeapon() { if (!_isEquipped) yield break; // Cancel block and power up if (_isBlocking) { _isBlocking = false; _inputReader.onWalkToggled?.Invoke(); } if (_isPoweringUp) { _isPoweringUp = false; if (_animator != null) _animator.SetBool(_powerUpHash, false); } // Play the Unequip animation if (_meleeLayerIndex != -1) { _animator.Play("Unequip", _meleeLayerIndex, 0f); } // Wait for the unequip animation to finish before snapping to the back // Adjust this 0.5f duration to match the length of your actual unequip animation! yield return new WaitForSeconds(0.5f); _isEquipped = false; UpdateWeaponSocket(); } private void UpdateWeaponSocket() { if (_currentWeapon == null) return; Transform targetSocket = _isEquipped ? _equippedSocket : _unequippedSocket; if (targetSocket != null) { _currentWeapon.transform.SetParent(targetSocket, false); if (_isEquipped) { _currentWeapon.transform.localPosition = _currentWeapon.equippedPositionOffset; _currentWeapon.transform.localRotation = Quaternion.Euler(_currentWeapon.equippedRotationOffset); } else { _currentWeapon.transform.localPosition = _currentWeapon.unequippedPositionOffset; _currentWeapon.transform.localRotation = Quaternion.Euler(_currentWeapon.unequippedRotationOffset); } } } #region Input Handlers private float _comboPenaltyEndTime = 0f; private void OnAttackStarted(InputAction.CallbackContext context) { if (!_isEquipped || _isBlocking) return; // 1. Penalty Check (Are we locked out for missing a combo?) if (Time.time < _comboPenaltyEndTime) { Debug.Log("[Combat Debug] Attack locked out! You must recover from your missed combo."); return; } // 2. Stamina Check (Requires at least 15 stamina to swing the sword) if (_playerStats != null && _playerStats.currentStamina < 15f) { Debug.Log("[Combat Debug] Not enough stamina to attack!"); return; } // 3. Input Debounce (0.4s) // This is long enough to stop jitter, but short enough that if you click // in the middle of your current attack, it successfully buffers the next Combo hit! if (Time.time < _lastAttackInputTime + 0.4f) { return; } // --- Debug Log --- Debug.Log($"[Combat Debug] Attack PRESSED at {Time.time:F2}s. (Last pressed: {_lastAttackInputTime:F2}s). If not playing, it last finished at: {_lastAttackAnimationEndTime:F2}s"); _lastAttackInputTime = Time.time; // ----------------- _isPoweringUp = true; if (_animator != null) { _animator.SetBool(_powerUpHash, true); } } private void OnAttackCanceled(InputAction.CallbackContext context) { if (_isPoweringUp) { // --- Debug Log --- Debug.Log($"[Combat Debug] Attack RELEASED at {Time.time:F2}s. Triggering Attack()!"); // ----------------- _isPoweringUp = false; if (_animator != null) { _animator.SetBool(_powerUpHash, false); } Attack(); } } private void OnBlockStarted(InputAction.CallbackContext context) { // Don't allow blocking if the weapon is put away if (!_isEquipped) return; _isBlocking = true; if (_playerStats != null) { _playerStats.staminaRegenMultiplier = 0.5f; // Half regen while blocking _playerStats.isBlocking = true; _playerStats.currentBlockReduction = _currentWeapon != null ? _currentWeapon.blockReductionPercentage : 0.5f; } // Slow down to walk speed while blocking _inputReader.onWalkToggled?.Invoke(); } private void OnBlockCanceled(InputAction.CallbackContext context) { if (_isBlocking) { _isBlocking = false; if (_playerStats != null) { _playerStats.staminaRegenMultiplier = 1f; // Restore regen rate _playerStats.isBlocking = false; } // Return to normal speed _inputReader.onWalkToggled?.Invoke(); } } #endregion #region Combat Actions private float _lastStaminaDrainTime = 0f; private bool _isComboWindowOpen = false; private bool _comboSuccessfullyBuffered = false; // --- Animation Events to add to your attack clips! --- public void OpenComboWindow() { _isComboWindowOpen = true; _comboSuccessfullyBuffered = false; // Reset the buffer for the new attack! if (_comboIndicatorUI != null) { _comboIndicatorUI.sprite = _comboStandbySprite; _comboIndicatorUI.color = _comboStandbyColor; _comboIndicatorUI.gameObject.SetActive(true); } } public void CloseComboWindow() { _isComboWindowOpen = false; if (_comboIndicatorUI != null) { // Only hide it if it hasn't been changed to the missed sprite (so missed icon has time to show) if (_comboIndicatorUI.sprite == _comboStandbySprite || _comboIndicatorUI.color == _comboSuccessColor) { _comboIndicatorUI.gameObject.SetActive(false); } } } // ----------------------------------------------------- public void Attack() { if (_animator != null) { if (_isAttacking) { // If they already earned the combo, ignore their extra spam clicks! // Otherwise, the penalty block below will wipe their hard-earned trigger! if (_comboSuccessfullyBuffered) return; // If they click during an attack, it MUST be inside the combo window! if (_isComboWindowOpen) { Debug.Log("[Combat Debug] PERFECT TIMING! Combo buffered."); _animator.SetTrigger(_attackHash); _comboSuccessfullyBuffered = true; // Lock in the success! if (_comboIndicatorUI != null) { _comboIndicatorUI.color = _comboSuccessColor; } // We set it to false so they can't trigger again, but we don't call CloseComboWindow // directly so the Success color stays visible for a split second! _isComboWindowOpen = false; } else { Debug.Log("[Combat Debug] Combo Dropped! Clicked too early or too late."); // PUNISHMENT: Lock out attacks for 1 full second and explicitly wipe the trigger // so the Animator is absolutely forbidden from storing up a delayed attack! _comboPenaltyEndTime = Time.time + 1.0f; _animator.ResetTrigger(_attackHash); if (_comboIndicatorUI != null) { _comboIndicatorUI.sprite = _comboMissedSprite; _comboIndicatorUI.color = _comboMissedColor; _comboIndicatorUI.gameObject.SetActive(true); StopCoroutine(HideMissedComboIcon()); StartCoroutine(HideMissedComboIcon()); } } } else { // Not attacking, just start the first attack normally _animator.SetTrigger(_attackHash); } } } private System.Collections.IEnumerator HideMissedComboIcon() { yield return new WaitForSeconds(0.4f); if (_comboIndicatorUI != null && _comboIndicatorUI.sprite == _comboMissedSprite) { _comboIndicatorUI.gameObject.SetActive(false); } } /// /// Performs hit detection. Call this from an Animation Event on the Attack animation. /// /// /// Alias for Synty's built-in "Hit" animation event. /// public void Hit() { Debug.Log("Animation Event 'Hit' triggered!"); DealDamage(); } public void DealDamage() { Debug.Log("DealDamage called!"); // Drain stamina EXACTLY when the sword swings, not when the button is clicked! if (_playerStats != null) { _playerStats.UseStamina(15f); } if (_currentWeapon == null) { Debug.LogWarning("No weapon assigned to deal damage!"); return; } Collider[] hitColliders; if (_currentWeapon.hitShape == Weapon.HitShape.Sphere) { hitColliders = Physics.OverlapSphere(_currentWeapon.GetHitCenter(), _currentWeapon.attackRadius, _targetLayers, QueryTriggerInteraction.Ignore); } else { hitColliders = Physics.OverlapBox(_currentWeapon.GetHitCenter(), _currentWeapon.hitBoxHalfExtents, _currentWeapon.GetHitRotation(), _targetLayers, QueryTriggerInteraction.Ignore); } foreach (Collider hit in hitColliders) { Debug.Log($"Melee hit {hit.name} for {_currentWeapon.damage} damage!"); hit.GetComponentInParent()?.TakeDamage(_currentWeapon.damage); hit.GetComponentInParent()?.TakeDamage(_currentWeapon.damage); hit.GetComponentInParent()?.TakeDamage(_currentWeapon.damage); } } // Add hashes for blocking animations private readonly int _blockHitHash = Animator.StringToHash("BlockHit"); private readonly int _blockBreakHash = Animator.StringToHash("BlockBreak"); private readonly int _blockPushHash = Animator.StringToHash("BlockPush"); public void TakeDamage(float amount) { if (_isBlocking) { float blockReduction = _currentWeapon != null ? _currentWeapon.blockReductionPercentage : 0.5f; float blockedAmount = amount * blockReduction; float damageTaken = amount - blockedAmount; if (_playerStats != null) { // We consume stamina equal to the damage blocked if (_playerStats.currentStamina >= blockedAmount) { Debug.Log($"Blocked {blockedAmount} damage! Cost {blockedAmount} stamina. Took {damageTaken} damage."); _playerStats.UseStamina(blockedAmount); _playerStats.TakeDamage(damageTaken); if (_animator != null) _animator.SetTrigger(_blockHitHash); return; } else { Debug.Log($"Block broken! Took full {amount} damage."); _isBlocking = false; _playerStats.staminaRegenMultiplier = 1f; _playerStats.UseStamina(_playerStats.currentStamina); // Drain rest of stamina _playerStats.PauseStaminaRegen(3f); // Pause regen for 3 seconds _inputReader.onWalkToggled?.Invoke(); // Stop walking slow _playerStats.TakeDamage(amount); if (_animator != null) _animator.SetTrigger(_blockBreakHash); return; } } } Debug.Log($"Took {amount} damage!"); if (_playerStats != null) { _playerStats.TakeDamage(amount); } if (_animator != null) { _animator.SetTrigger(_takeDamageHash); } } #endregion private void LateUpdate() { // If we are not attacking and the equip weight is 0, don't bend the spine if (_spineBone == null || _cameraTransform == null) return; if (_equipWeight <= 0f && !_isAttacking) return; // Calculate the camera's pitch (up/down rotation) // We use the camera's forward vector compared to the horizontal plane Vector3 cameraForward = _cameraTransform.forward; Vector3 horizontalForward = new Vector3(cameraForward.x, 0, cameraForward.z).normalized; float pitch = Vector3.SignedAngle(horizontalForward, cameraForward, _cameraTransform.right); // Clamp the pitch so we don't break the spine backward pitch = Mathf.Clamp(pitch, -45f, 45f); // Apply pitch to the spine bone. // If we are doing a full-body attack, force the blend weight to 1.0! float blendWeight = _isAttacking ? 1.0f : _equipWeight; // We rotate around the character's right axis to lean forward/backward _spineBone.rotation = Quaternion.AngleAxis(pitch * blendWeight, transform.right) * _spineBone.rotation; } }