started building NPC combat
This commit is contained in:
384
Assets/Scripts/NPCController.cs
Normal file
384
Assets/Scripts/NPCController.cs
Normal file
@@ -0,0 +1,384 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user