started building NPC combat

This commit is contained in:
2026-09-29 16:11:54 +01:00
parent be2e7a5824
commit 7486c8d310
4952 changed files with 1093899 additions and 745 deletions

View 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);
}
}