601 lines
21 KiB
C#
601 lines
21 KiB
C#
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: "<Mouse>/leftButton");
|
|
[Tooltip("Input action for blocking")]
|
|
[SerializeField] private InputAction _blockAction = new InputAction("Block", binding: "<Mouse>/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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs hit detection. Call this from an Animation Event on the Attack animation.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Alias for Synty's built-in "Hit" animation event.
|
|
/// </summary>
|
|
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<WorldObject>()?.TakeDamage(_currentWeapon.damage);
|
|
hit.GetComponentInParent<NPCController>()?.TakeDamage(_currentWeapon.damage);
|
|
hit.GetComponentInParent<PlayerStats>()?.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;
|
|
}
|
|
|
|
|
|
}
|