385 lines
12 KiB
C#
385 lines
12 KiB
C#
|
|
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<NavMeshAgent>();
|
||
|
|
|
||
|
|
if (_animator == null)
|
||
|
|
{
|
||
|
|
_animator = GetComponentInChildren<Animator>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Auto-assign player as combat target if empty (great for quick testing!)
|
||
|
|
if (combatTarget == null)
|
||
|
|
{
|
||
|
|
PlayerStats p = FindObjectOfType<PlayerStats>();
|
||
|
|
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<Transform>();
|
||
|
|
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<Weapon>();
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Call this from the "Hit" Animation Event on the NPC's attack animation
|
||
|
|
/// </summary>
|
||
|
|
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<PlayerStats>();
|
||
|
|
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<Collider>();
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Helper method: Call this from other scripts to make the NPC walk to a target!
|
||
|
|
/// </summary>
|
||
|
|
public void WalkTo(Vector3 destination)
|
||
|
|
{
|
||
|
|
if (_agent == null || !_agent.isOnNavMesh) return;
|
||
|
|
_agent.speed = npcData.walkSpeed;
|
||
|
|
_agent.isStopped = false;
|
||
|
|
_agent.SetDestination(destination);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Helper method: Call this from other scripts to make the NPC run to a target!
|
||
|
|
/// </summary>
|
||
|
|
public void RunTo(Vector3 destination)
|
||
|
|
{
|
||
|
|
if (_agent == null || !_agent.isOnNavMesh) return;
|
||
|
|
_agent.speed = npcData.runSpeed;
|
||
|
|
_agent.isStopped = false;
|
||
|
|
_agent.SetDestination(destination);
|
||
|
|
}
|
||
|
|
}
|