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("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 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 void Start() { _agent = GetComponent(); if (_animator == null) { _animator = GetComponentInChildren(); } // 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 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); if (distToTarget <= npcData.attackRadius) { // We are close enough to attack! Stop moving 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); } // Attack if cooldown is ready if (Time.time >= _lastAttackTime + npcData.attackCooldown) { PerformAttack(); } } else { // Target is too far, chase them! RunTo(combatTarget.position); } } private void PerformAttack() { _lastAttackTime = Time.time; if (_animator != null) { _animator.SetTrigger(_attackHash); } } /// /// 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) { PlayerStats playerStats = combatTarget.GetComponent(); if (playerStats != null) { float damageToDeal = npcData.weaponData != null ? npcData.weaponData.damage : npcData.baseDamage; Debug.Log($"NPC landed a hit on the Player for {damageToDeal} damage!"); playerStats.TakeDamage(damageToDeal); } } } public void TakeDamage(float amount) { if (_isDead) return; 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; if (_agent != null && _agent.isOnNavMesh) { _agent.isStopped = true; } if (_animator != null) { _animator.SetTrigger(_deathHash); } // Disable ALL colliders on this object so it doesn't block the player or stay lock-on-able Collider[] cols = GetComponents(); foreach(Collider col in cols) { col.enabled = false; } // Specifically disable the lock-on script if it exists MonoBehaviour lockOnScript = GetComponent("SampleObjectLockOn") as MonoBehaviour; if (lockOnScript != null) { lockOnScript.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); } }