Files
FablishRPG/Assets/Scripts/NPCController.cs

523 lines
18 KiB
C#
Raw Normal View History

2026-09-29 16:11:54 +01:00
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;
2026-09-29 16:11:54 +01:00
[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;
2026-09-29 16:11:54 +01:00
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");
2026-09-29 16:11:54 +01:00
private void Start()
{
_agent = GetComponent<NavMeshAgent>();
if (_animator == null)
{
_animator = GetComponentInChildren<Animator>();
}
// Gather all rigidbodies that belong to the ragdoll
_boneRigidbodies = GetComponentsInChildren<Rigidbody>();
SetRagdollState(false); // Make them kinematic while alive
2026-09-29 16:11:54 +01:00
// 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 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;
}
}
2026-09-29 16:11:54 +01:00
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<PlayerStats>();
MeleeCombatLayerController playerCombat = combatTarget.GetComponent<MeleeCombatLayerController>();
bool isPlayerBlocking = playerStats != null && playerStats.isBlocking;
bool isPlayerAttacking = playerCombat != null && playerCombat.IsAttacking;
2026-09-29 16:11:54 +01:00
if (distToTarget <= npcData.attackRadius)
{
// Stop and face them
2026-09-29 16:11:54 +01:00
_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)
2026-09-29 16:11:54 +01:00
{
_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();
}
2026-09-29 16:11:54 +01:00
}
}
else
{
// Target is too far, chase them! (And stop blocking if we were)
if (_isBlocking) SetBlockState(false);
2026-09-29 16:11:54 +01:00
RunTo(combatTarget.position);
}
}
private void SetBlockState(bool block)
{
_isBlocking = block;
if (_animator != null)
{
_animator.SetBool(_isBlockingHash, block);
}
}
2026-09-29 16:11:54 +01:00
private void PerformAttack()
{
_lastAttackTime = Time.time;
if (_animator != null)
{
_animator.SetTrigger(_attackHash);
}
}
/// <summary>
/// Some Synty animations use "DealDamage" instead of "Hit" as the event name.
/// </summary>
public void DealDamage()
{
Hit();
}
2026-09-29 16:11:54 +01:00
/// <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)
{
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<MeleeCombatLayerController>();
if (playerCombat != null)
2026-09-29 16:11:54 +01:00
{
playerCombat.TakeDamage(damageToDeal);
}
else
{
PlayerStats playerStats = combatTarget.GetComponent<PlayerStats>();
if (playerStats != null) playerStats.TakeDamage(damageToDeal);
2026-09-29 16:11:54 +01:00
}
}
}
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);
}
2026-09-29 16:11:54 +01:00
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<QuestInteractable>();
if (questTarget != null)
{
questTarget.TriggerQuestProgress();
}
2026-09-29 16:11:54 +01:00
if (_agent != null && _agent.isOnNavMesh)
{
_agent.isStopped = true;
_agent.enabled = false; // Turn off the agent completely so physics don't fight it
2026-09-29 16:11:54 +01:00
}
if (useRagdoll)
2026-09-29 16:11:54 +01:00
{
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);
}
2026-09-29 16:11:54 +01:00
}
// Disable ALL root colliders on this object (capsules) so it doesn't block the player
2026-09-29 16:11:54 +01:00
Collider[] cols = GetComponents<Collider>();
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<MonoBehaviour>())
{
if (mb != null && mb.GetType().Name == "SampleObjectLockOn")
{
lockOnScript = mb;
break;
}
}
2026-09-29 16:11:54 +01:00
if (lockOnScript != null)
{
lockOnScript.enabled = false;
// Also disable its collider so we can't lock on anymore!
Collider lockOnCol = lockOnScript.GetComponent<Collider>();
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<Collider>();
foreach (Collider c in allCols)
{
if (c.isTrigger) c.enabled = false;
2026-09-29 16:11:54 +01:00
}
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);
}
#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
2026-09-29 16:11:54 +01:00
}