2026-09-28 20:46:57 +01:00
|
|
|
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;
|
|
|
|
|
|
2026-09-29 16:11:54 +01:00
|
|
|
[Tooltip("The player's stats script for stamina usage")]
|
|
|
|
|
[SerializeField] private PlayerStats _playerStats;
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
[Header("Weapon Management")]
|
|
|
|
|
[Tooltip("The active weapon used for damage and stats.")]
|
|
|
|
|
[SerializeField] private Weapon _currentWeapon;
|
|
|
|
|
|
|
|
|
|
[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;
|
|
|
|
|
|
2026-09-29 16:11:54 +01:00
|
|
|
private Transform _spineBone;
|
2026-09-28 20:46:57 +01:00
|
|
|
|
|
|
|
|
private void Start()
|
|
|
|
|
{
|
|
|
|
|
_meleeLayerIndex = _animator.GetLayerIndex(_meleeLayerName);
|
2026-09-29 16:11:54 +01:00
|
|
|
_spineBone = _animator.GetBoneTransform(HumanBodyBones.Spine);
|
2026-09-28 20:46:57 +01:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 16:11:54 +01:00
|
|
|
private bool _isAttacking;
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
private void Update()
|
|
|
|
|
{
|
2026-09-29 16:11:54 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-28 20:46:57 +01:00
|
|
|
_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;
|
|
|
|
|
|
2026-09-29 16:11:54 +01:00
|
|
|
if (_currentWeapon == null)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogWarning("Cannot equip! No melee weapon is assigned.");
|
|
|
|
|
yield break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
_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 void OnAttackStarted(InputAction.CallbackContext context)
|
|
|
|
|
{
|
|
|
|
|
if (!_isEquipped || _isBlocking) return;
|
|
|
|
|
|
|
|
|
|
_isPoweringUp = true;
|
|
|
|
|
if (_animator != null)
|
|
|
|
|
{
|
|
|
|
|
_animator.SetBool(_powerUpHash, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnAttackCanceled(InputAction.CallbackContext context)
|
|
|
|
|
{
|
|
|
|
|
if (_isPoweringUp)
|
|
|
|
|
{
|
|
|
|
|
_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;
|
|
|
|
|
|
2026-09-29 16:11:54 +01:00
|
|
|
if (_playerStats != null)
|
|
|
|
|
{
|
|
|
|
|
_playerStats.staminaRegenMultiplier = 0.5f; // Half regen while blocking
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
// Slow down to walk speed while blocking
|
|
|
|
|
_inputReader.onWalkToggled?.Invoke();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnBlockCanceled(InputAction.CallbackContext context)
|
|
|
|
|
{
|
|
|
|
|
if (_isBlocking)
|
|
|
|
|
{
|
|
|
|
|
_isBlocking = false;
|
|
|
|
|
|
2026-09-29 16:11:54 +01:00
|
|
|
if (_playerStats != null)
|
|
|
|
|
{
|
|
|
|
|
_playerStats.staminaRegenMultiplier = 1f; // Restore regen rate
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
// Return to normal speed
|
|
|
|
|
_inputReader.onWalkToggled?.Invoke();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Combat Actions
|
|
|
|
|
|
|
|
|
|
public void Attack()
|
|
|
|
|
{
|
|
|
|
|
if (_animator != null)
|
|
|
|
|
{
|
2026-09-29 16:11:54 +01:00
|
|
|
// Optional: You can check if there's enough stamina to attack here
|
|
|
|
|
// e.g. if (_playerStats != null && !_playerStats.HasStaminaPercentage(0.1f)) return;
|
|
|
|
|
|
|
|
|
|
if (_playerStats != null)
|
|
|
|
|
{
|
|
|
|
|
_playerStats.UseStamina(15f); // Use 15 stamina per swing
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
_animator.SetTrigger(_attackHash);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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()
|
|
|
|
|
{
|
2026-09-29 16:11:54 +01:00
|
|
|
Debug.Log("Animation Event 'Hit' triggered!");
|
2026-09-28 20:46:57 +01:00
|
|
|
DealDamage();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void DealDamage()
|
|
|
|
|
{
|
2026-09-29 16:11:54 +01:00
|
|
|
Debug.Log("DealDamage called!");
|
2026-09-28 20:46:57 +01:00
|
|
|
if (_currentWeapon == null)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogWarning("No weapon assigned to deal damage!");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Collider[] hitColliders;
|
|
|
|
|
|
|
|
|
|
if (_currentWeapon.hitShape == Weapon.HitShape.Sphere)
|
|
|
|
|
{
|
2026-09-29 16:11:54 +01:00
|
|
|
hitColliders = Physics.OverlapSphere(_currentWeapon.GetHitCenter(), _currentWeapon.attackRadius, _targetLayers, QueryTriggerInteraction.Ignore);
|
2026-09-28 20:46:57 +01:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-09-29 16:11:54 +01:00
|
|
|
hitColliders = Physics.OverlapBox(_currentWeapon.GetHitCenter(), _currentWeapon.hitBoxHalfExtents, _currentWeapon.GetHitRotation(), _targetLayers, QueryTriggerInteraction.Ignore);
|
2026-09-28 20:46:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (Collider hit in hitColliders)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log($"Melee hit {hit.name} for {_currentWeapon.damage} damage!");
|
|
|
|
|
hit.GetComponent<WorldObject>()?.TakeDamage(_currentWeapon.damage);
|
2026-09-29 16:11:54 +01:00
|
|
|
hit.GetComponent<NPCController>()?.TakeDamage(_currentWeapon.damage);
|
|
|
|
|
hit.GetComponent<PlayerStats>()?.TakeDamage(_currentWeapon.damage);
|
2026-09-28 20:46:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void TakeDamage(float amount)
|
|
|
|
|
{
|
|
|
|
|
if (_isBlocking)
|
|
|
|
|
{
|
2026-09-29 16:11:54 +01:00
|
|
|
if (_playerStats != null)
|
|
|
|
|
{
|
|
|
|
|
if (_playerStats.currentStamina >= amount)
|
|
|
|
|
{
|
|
|
|
|
Debug.Log($"Blocked {amount} damage! Cost {amount} stamina.");
|
|
|
|
|
_playerStats.UseStamina(amount);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
Debug.Log($"Block broken! Took {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
|
|
|
|
|
|
|
|
|
|
// Optionally, play a guard break animation here if you have one
|
|
|
|
|
// _animator.SetTrigger("GuardBreak");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
Debug.Log($"Blocked {amount} damage!");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-28 20:46:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Debug.Log($"Took {amount} damage!");
|
2026-09-29 16:11:54 +01:00
|
|
|
if (_playerStats != null)
|
|
|
|
|
{
|
|
|
|
|
_playerStats.TakeDamage(amount);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
if (_animator != null)
|
|
|
|
|
{
|
|
|
|
|
_animator.SetTrigger(_takeDamageHash);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
2026-09-29 16:11:54 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 20:46:57 +01:00
|
|
|
|
|
|
|
|
}
|