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 ;
2026-09-30 16:39:18 +01:00
[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 ;
2026-09-28 20:46:57 +01:00
[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-30 16:39:18 +01:00
public bool IsAttacking = > _isAttacking ;
// --- Debug Tracking ---
private float _lastAttackInputTime = 0f ;
private float _lastAttackAnimationEndTime = 0f ;
private bool _wasAttackingLastFrame = false ;
2026-09-29 16:11:54 +01:00
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 ;
}
}
2026-09-30 16:39:18 +01:00
// 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 ;
2026-09-29 16:11:54 +01:00
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
2026-09-30 16:39:18 +01:00
private float _comboPenaltyEndTime = 0f ;
2026-09-28 20:46:57 +01:00
private void OnAttackStarted ( InputAction . CallbackContext context )
{
if ( ! _isEquipped | | _isBlocking ) return ;
2026-09-30 16:39:18 +01:00
// 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 ;
// -----------------
2026-09-28 20:46:57 +01:00
_isPoweringUp = true ;
if ( _animator ! = null )
{
_animator . SetBool ( _powerUpHash , true ) ;
}
}
private void OnAttackCanceled ( InputAction . CallbackContext context )
{
if ( _isPoweringUp )
{
2026-09-30 16:39:18 +01:00
// --- Debug Log ---
Debug . Log ( $"[Combat Debug] Attack RELEASED at {Time.time:F2}s. Triggering Attack()!" ) ;
// -----------------
2026-09-28 20:46:57 +01:00
_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-30 16:39:18 +01:00
_playerStats . isBlocking = true ;
_playerStats . currentBlockReduction = _currentWeapon ! = null ? _currentWeapon . blockReductionPercentage : 0.5f ;
2026-09-29 16:11:54 +01:00
}
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-30 16:39:18 +01:00
_playerStats . isBlocking = false ;
2026-09-29 16:11:54 +01:00
}
2026-09-28 20:46:57 +01:00
// Return to normal speed
_inputReader . onWalkToggled ? . Invoke ( ) ;
}
}
#endregion
#region Combat Actions
2026-09-30 16:39:18 +01:00
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 ) ;
}
}
}
// -----------------------------------------------------
2026-09-28 20:46:57 +01:00
public void Attack ( )
{
if ( _animator ! = null )
{
2026-09-30 16:39:18 +01:00
if ( _isAttacking )
2026-09-29 16:11:54 +01:00
{
2026-09-30 16:39:18 +01:00
// 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 ) ;
2026-09-29 16:11:54 +01:00
}
2026-09-30 16:39:18 +01:00
}
}
private System . Collections . IEnumerator HideMissedComboIcon ( )
{
yield return new WaitForSeconds ( 0.4f ) ;
if ( _comboIndicatorUI ! = null & & _comboIndicatorUI . sprite = = _comboMissedSprite )
{
_comboIndicatorUI . gameObject . SetActive ( false ) ;
2026-09-28 20:46:57 +01:00
}
}
/// <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-30 16:39:18 +01:00
// Drain stamina EXACTLY when the sword swings, not when the button is clicked!
if ( _playerStats ! = null )
{
_playerStats . UseStamina ( 15f ) ;
}
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!" ) ;
2026-09-30 16:39:18 +01:00
hit . GetComponentInParent < WorldObject > ( ) ? . TakeDamage ( _currentWeapon . damage ) ;
hit . GetComponentInParent < NPCController > ( ) ? . TakeDamage ( _currentWeapon . damage ) ;
hit . GetComponentInParent < PlayerStats > ( ) ? . TakeDamage ( _currentWeapon . damage ) ;
2026-09-28 20:46:57 +01:00
}
}
2026-09-30 16:39:18 +01:00
// 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" ) ;
2026-09-28 20:46:57 +01:00
public void TakeDamage ( float amount )
{
if ( _isBlocking )
{
2026-09-30 16:39:18 +01:00
float blockReduction = _currentWeapon ! = null ? _currentWeapon . blockReductionPercentage : 0.5f ;
float blockedAmount = amount * blockReduction ;
float damageTaken = amount - blockedAmount ;
2026-09-29 16:11:54 +01:00
if ( _playerStats ! = null )
{
2026-09-30 16:39:18 +01:00
// We consume stamina equal to the damage blocked
if ( _playerStats . currentStamina > = blockedAmount )
2026-09-29 16:11:54 +01:00
{
2026-09-30 16:39:18 +01:00
Debug . Log ( $"Blocked {blockedAmount} damage! Cost {blockedAmount} stamina. Took {damageTaken} damage." ) ;
_playerStats . UseStamina ( blockedAmount ) ;
_playerStats . TakeDamage ( damageTaken ) ;
if ( _animator ! = null ) _animator . SetTrigger ( _blockHitHash ) ;
2026-09-29 16:11:54 +01:00
return ;
}
else
{
2026-09-30 16:39:18 +01:00
Debug . Log ( $"Block broken! Took full {amount} damage." ) ;
2026-09-29 16:11:54 +01:00
_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
2026-09-30 16:39:18 +01:00
_playerStats . TakeDamage ( amount ) ;
if ( _animator ! = null ) _animator . SetTrigger ( _blockBreakHash ) ;
return ;
2026-09-29 16:11:54 +01:00
}
}
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
}