using UnityEngine; using UnityEngine.AI; public enum NPCFaction { Friendly, // Never attacks Neutral, // Only attacks if attacked first Enemy // Attacks on sight } [RequireComponent(typeof(NavMeshAgent))] public class NPCController : MonoBehaviour { private NavMeshAgent _agent; [Tooltip("Leave blank to automatically find the Animator in child objects")] [SerializeField] private Animator _animator; [Header("NPC Configuration")] public NPCData npcData; [Tooltip("The socket on the character model where the weapon should spawn (e.g., Prop_R). Leave blank to auto-search.")] public Transform weaponSocket; [Header("Ragdoll Settings")] [Tooltip("If true, the NPC will collapse into a ragdoll on death rather than playing an animation.")] public bool useRagdoll = true; private Rigidbody[] _boneRigidbodies; [Header("Patrol Testing")] [Tooltip("If true, the NPC will automatically walk between the patrol points below.")] public bool isPatrolling = false; [Tooltip("Drag empty GameObjects here to act as waypoints.")] public Transform[] patrolPoints; [Tooltip("How many seconds to wait at each point before moving to the next one.")] public float waitTimeAtPoint = 2.0f; [Header("Runtime State")] public float currentHealth; public NPCFaction currentFaction; [Tooltip("If assigned, the NPC will abandon patrol to chase and attack this target.")] public Transform combatTarget; private int _currentPatrolIndex = 0; private float _patrolWaitTimer = 0f; private float _lastAttackTime = 0f; private float _stunTimer = 0f; private bool _isDead = false; private bool _isBlocking = false; private float _lastBlockAttemptTime = 0f; private GameObject _equippedWeaponInstance; // Animator Hashes specifically for Synty's AC_Sidekick and AC_Polygon controllers private readonly int _movementInputHeldHash = Animator.StringToHash("MovementInputHeld"); private readonly int _movementInputPressedHash = Animator.StringToHash("MovementInputPressed"); private readonly int _moveSpeedHash = Animator.StringToHash("MoveSpeed"); private readonly int _currentGaitHash = Animator.StringToHash("CurrentGait"); private readonly int _isGroundedHash = Animator.StringToHash("IsGrounded"); private readonly int _isWalkingHash = Animator.StringToHash("IsWalking"); private readonly int _isStoppedHash = Animator.StringToHash("IsStopped"); private bool _wasMovingLastFrame = false; // Additional Animator Hashes for Combat private readonly int _attackHash = Animator.StringToHash("Attack"); private readonly int _takeDamageHash = Animator.StringToHash("TakeDamage"); private readonly int _deathHash = Animator.StringToHash("Death"); // Optional private readonly int _isBlockingHash = Animator.StringToHash("IsBlocking"); private void Start() { _agent = GetComponent(); if (_animator == null) { _animator = GetComponentInChildren(); } // Gather all rigidbodies that belong to the ragdoll _boneRigidbodies = GetComponentsInChildren(); SetRagdollState(false); // Make them kinematic while alive // Auto-assign player as combat target if empty (great for quick testing!) if (combatTarget == null) { PlayerStats p = FindObjectOfType(); if (p != null) combatTarget = p.transform; } if (npcData != null) { currentHealth = npcData.maxHealth; currentFaction = npcData.defaultFaction; EquipWeapon(); } else { Debug.LogWarning($"NPC {gameObject.name} is missing an NPCData object!"); } // Let the NavMeshAgent natively handle where the NPC is facing and moving _agent.updatePosition = true; _agent.updateRotation = true; if (isPatrolling && patrolPoints.Length > 0) { WalkTo(patrolPoints[_currentPatrolIndex].position); } } private void EquipWeapon() { if (npcData.weaponPrefab == null) return; // Try to automatically find a Prop_R socket if not assigned if (weaponSocket == null) { // Synty characters often have a Prop_R under the right hand Transform[] allChildren = GetComponentsInChildren(); foreach (Transform t in allChildren) { if (t.name == "Prop_R" || t.name == "Weapon_R") { weaponSocket = t; break; } } } if (weaponSocket != null) { _equippedWeaponInstance = Instantiate(npcData.weaponPrefab, weaponSocket); // If the weapon prefab has a Weapon script on it, we might want to apply offsets Weapon weaponComp = _equippedWeaponInstance.GetComponent(); if (weaponComp != null) { _equippedWeaponInstance.transform.localPosition = weaponComp.equippedPositionOffset; _equippedWeaponInstance.transform.localRotation = Quaternion.Euler(weaponComp.equippedRotationOffset); } else { _equippedWeaponInstance.transform.localPosition = Vector3.zero; _equippedWeaponInstance.transform.localRotation = Quaternion.identity; } } } private void SetRagdollState(bool isActive) { if (_boneRigidbodies == null) return; foreach (Rigidbody rb in _boneRigidbodies) { // We ignore the root GameObject's Rigidbody if it has one if (rb.gameObject == gameObject) continue; rb.isKinematic = !isActive; } } private void Update() { if (_isDead) return; // Handle Stun / Hit Reaction if (_stunTimer > 0f) { _stunTimer -= Time.deltaTime; if (_agent != null && _agent.isOnNavMesh) _agent.isStopped = true; UpdateAnimatorLocomotion(); return; } // Combat Override (Only if they are an enemy) bool shouldAggro = combatTarget != null && currentFaction == NPCFaction.Enemy; if (shouldAggro && Vector3.Distance(transform.position, combatTarget.position) <= npcData.aggroRadius) { UpdateCombat(); } else if (isPatrolling) { UpdatePatrol(); } else { // If neither patrolling nor fighting, just stop if (_agent != null && _agent.isOnNavMesh && !_agent.isStopped) { _agent.isStopped = true; } } UpdateAnimatorLocomotion(); } private void UpdateCombat() { if (combatTarget == null || _agent == null || !_agent.isOnNavMesh) return; float distToTarget = Vector3.Distance(transform.position, combatTarget.position); PlayerStats playerStats = combatTarget.GetComponent(); MeleeCombatLayerController playerCombat = combatTarget.GetComponent(); bool isPlayerBlocking = playerStats != null && playerStats.isBlocking; bool isPlayerAttacking = playerCombat != null && playerCombat.IsAttacking; if (distToTarget <= npcData.attackRadius) { // Stop and face them _agent.isStopped = true; Vector3 direction = (combatTarget.position - transform.position).normalized; direction.y = 0; if (direction != Vector3.zero) { transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * 5f); } // Blocking Logic: Try to block if the player is attacking if (isPlayerAttacking && !_isBlocking && Time.time >= _lastBlockAttemptTime + 1.5f) { _lastBlockAttemptTime = Time.time; if (Random.value <= npcData.blockChance) { SetBlockState(true); } } // Stop blocking if the player stops attacking (or after a small delay) if (!isPlayerAttacking && _isBlocking && Time.time >= _lastBlockAttemptTime + 1.0f) { SetBlockState(false); } // Attacking Logic: Only attack if we are NOT currently blocking if (!_isBlocking) { // If the player is turtling (blocking), attack twice as fast to try and break their guard! float currentCooldown = isPlayerBlocking ? npcData.attackCooldown * 0.5f : npcData.attackCooldown; if (Time.time >= _lastAttackTime + currentCooldown) { PerformAttack(); } } } else { // Target is too far, chase them! (And stop blocking if we were) if (_isBlocking) SetBlockState(false); RunTo(combatTarget.position); } } private void SetBlockState(bool block) { _isBlocking = block; if (_animator != null) { _animator.SetBool(_isBlockingHash, block); } } private void PerformAttack() { _lastAttackTime = Time.time; if (_animator != null) { _animator.SetTrigger(_attackHash); } } /// /// Some Synty animations use "DealDamage" instead of "Hit" as the event name. /// public void DealDamage() { Hit(); } /// /// Call this from the "Hit" Animation Event on the NPC's attack animation /// public void Hit() { if (_isDead || combatTarget == null) return; // If the target is still within attack range when the animation hits, deal damage if (Vector3.Distance(transform.position, combatTarget.position) <= npcData.attackRadius + 0.5f) { float damageToDeal = npcData.weaponData != null ? npcData.weaponData.damage : npcData.baseDamage; Debug.Log($"NPC landed a hit on the Player for {damageToDeal} damage!"); // Try to hit the combat controller first so it can process blocks/stamina/animations MeleeCombatLayerController playerCombat = combatTarget.GetComponent(); if (playerCombat != null) { playerCombat.TakeDamage(damageToDeal); } else { PlayerStats playerStats = combatTarget.GetComponent(); if (playerStats != null) playerStats.TakeDamage(damageToDeal); } } } public void TakeDamage(float amount) { if (_isDead) return; if (_isBlocking) { float blockedAmount = amount * npcData.blockReductionPercentage; amount -= blockedAmount; Debug.Log($"NPC Blocked {blockedAmount} damage! Taking only {amount} damage."); // Optionally: Break guard if hit hard? // SetBlockState(false); } currentHealth -= amount; Debug.Log($"NPC took {amount} damage! Health remaining: {currentHealth}"); // If a neutral NPC gets hit, they become hostile! if (currentFaction == NPCFaction.Neutral) { currentFaction = NPCFaction.Enemy; Debug.Log("Neutral NPC was attacked and is now hostile!"); } if (currentHealth <= 0) { Die(); } else { if (_animator != null) { _animator.SetTrigger(_takeDamageHash); // Stun them for 1 second so they don't slide while playing the hit animation _stunTimer = 1.0f; } } } private void Die() { _isDead = true; // Check if this NPC is a quest target! QuestInteractable questTarget = GetComponent(); if (questTarget != null) { questTarget.TriggerQuestProgress(); } if (_agent != null && _agent.isOnNavMesh) { _agent.isStopped = true; _agent.enabled = false; // Turn off the agent completely so physics don't fight it } if (useRagdoll) { if (_animator != null) _animator.enabled = false; // Turn off animator so physics take over SetRagdollState(true); // Let the limbs fall! } else { if (_animator != null) { _animator.SetTrigger(_deathHash); } } // Disable ALL root colliders on this object (capsules) so it doesn't block the player Collider[] cols = GetComponents(); foreach(Collider col in cols) { col.enabled = false; } // Find and disable the Lock-On script even if it's on a child object MonoBehaviour lockOnScript = null; foreach (MonoBehaviour mb in GetComponentsInChildren()) { if (mb != null && mb.GetType().Name == "SampleObjectLockOn") { lockOnScript = mb; break; } } if (lockOnScript != null) { lockOnScript.enabled = false; // Also disable its collider so we can't lock on anymore! Collider lockOnCol = lockOnScript.GetComponent(); if (lockOnCol != null) lockOnCol.enabled = false; } // Just to be absolutely safe, let's disable ALL trigger colliders in the children // (This catches the giant lock-on sphere without disabling the physical ragdoll bone colliders!) Collider[] allCols = GetComponentsInChildren(); foreach (Collider c in allCols) { if (c.isTrigger) c.enabled = false; } Debug.Log("NPC has died!"); } private void UpdateAnimatorLocomotion() { if (_animator == null) return; // 1. Is the NPC currently trying to move somewhere? bool isMoving = !_agent.isStopped && _agent.hasPath && _agent.remainingDistance > 0.1f && _agent.velocity.sqrMagnitude > 0.01f; // 2. We must feed the NavMeshAgent's movement state into Synty's Animation variables if (isMoving) { _animator.SetBool(_movementInputHeldHash, true); _animator.SetBool(_isStoppedHash, false); _animator.SetBool(_isWalkingHash, true); if (!_wasMovingLastFrame) { _animator.SetBool(_movementInputPressedHash, true); } else { _animator.SetBool(_movementInputPressedHash, false); } float currentSpeed = _agent.velocity.magnitude; _animator.SetFloat(_moveSpeedHash, currentSpeed); if (currentSpeed > npcData.walkSpeed + 0.5f) { _animator.SetInteger(_currentGaitHash, 2); // Run } else { _animator.SetInteger(_currentGaitHash, 1); // Walk } } else { _animator.SetBool(_movementInputHeldHash, false); _animator.SetBool(_movementInputPressedHash, false); _animator.SetBool(_isStoppedHash, true); _animator.SetBool(_isWalkingHash, false); _animator.SetFloat(_moveSpeedHash, 0f); _animator.SetInteger(_currentGaitHash, 0); // Idle } _animator.SetBool(_isGroundedHash, true); _wasMovingLastFrame = isMoving; } private void UpdatePatrol() { if (patrolPoints == null || patrolPoints.Length == 0 || _agent == null || !_agent.isOnNavMesh) return; // Check if we have arrived at our destination if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance + 0.1f) { _patrolWaitTimer += Time.deltaTime; // Once we've waited long enough, pick the next point if (_patrolWaitTimer >= waitTimeAtPoint) { _patrolWaitTimer = 0f; _currentPatrolIndex = (_currentPatrolIndex + 1) % patrolPoints.Length; WalkTo(patrolPoints[_currentPatrolIndex].position); } } } /// /// Helper method: Call this from other scripts to make the NPC walk to a target! /// public void WalkTo(Vector3 destination) { if (_agent == null || !_agent.isOnNavMesh) return; _agent.speed = npcData.walkSpeed; _agent.isStopped = false; _agent.SetDestination(destination); } /// /// Helper method: Call this from other scripts to make the NPC run to a target! /// public void RunTo(Vector3 destination) { if (_agent == null || !_agent.isOnNavMesh) return; _agent.speed = npcData.runSpeed; _agent.isStopped = false; _agent.SetDestination(destination); } #region Animation Event Dummies // These methods are here purely to absorb the Animation Events you added for the Player's Combo System. // Since NPCs use the exact same animation clips, they will fire the events too! // This stops Unity from throwing "has no receiver" errors. public void OpenComboWindow() { // NPCs don't have a UI combo system, so they just ignore this! } public void CloseComboWindow() { // Ignored! } #endregion }