started building NPC combat
This commit is contained in:
@@ -24,6 +24,13 @@ public class BowAimLayerController : MonoBehaviour
|
||||
[Tooltip("The Transform socket where the bow rests when put away (e.g., Spine_03)")]
|
||||
[SerializeField] private Transform _unequippedSocket;
|
||||
|
||||
[Header("Stats & Stamina")]
|
||||
[SerializeField] private PlayerStats _playerStats;
|
||||
[Tooltip("How much stamina per second is drained while holding the bow string back.")]
|
||||
[SerializeField] private float _staminaDrainRate = 15f;
|
||||
[Tooltip("How many seconds you need to hold the bow to reach maximum power and distance.")]
|
||||
[SerializeField] private float _maxDrawTime = 1.5f;
|
||||
|
||||
[Header("Projectile Settings")]
|
||||
[Tooltip("The arrow prefab to spawn. Make sure it has the Projectile.cs script!")]
|
||||
[SerializeField] private Projectile _arrowPrefab;
|
||||
@@ -86,6 +93,9 @@ public class BowAimLayerController : MonoBehaviour
|
||||
// Are we currently holding the draw button (left click)?
|
||||
private bool _isDrawing;
|
||||
private Projectile _loadedArrow;
|
||||
|
||||
private float _drawStartTime;
|
||||
private float _currentPowerMultiplier;
|
||||
|
||||
// The current 0-1 blend weight for the bow layer (driven by equip state)
|
||||
private float _equipWeight;
|
||||
@@ -208,6 +218,12 @@ public class BowAimLayerController : MonoBehaviour
|
||||
{
|
||||
if (_isEquipped) yield break;
|
||||
|
||||
if (_currentWeapon == null)
|
||||
{
|
||||
Debug.LogWarning("Cannot equip! No bow is assigned.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
_isEquipped = true;
|
||||
|
||||
_animator.Play("Equip", _aimLayer, 0f);
|
||||
@@ -319,8 +335,43 @@ public class BowAimLayerController : MonoBehaviour
|
||||
// Start drawing: left click pressed while aiming
|
||||
if (drawHeld && _isAiming && !_isDrawing)
|
||||
{
|
||||
_isDrawing = true;
|
||||
_animator.SetBool("IsDrawing", true);
|
||||
// Only allow drawing if stamina is at least 10%
|
||||
if (_playerStats == null || _playerStats.HasStaminaPercentage(0.10f))
|
||||
{
|
||||
_isDrawing = true;
|
||||
_drawStartTime = Time.time;
|
||||
_animator.SetBool("IsDrawing", true);
|
||||
}
|
||||
}
|
||||
|
||||
// While drawing, drain stamina and calculate power
|
||||
if (_isDrawing)
|
||||
{
|
||||
float drawDuration = Time.time - _drawStartTime;
|
||||
|
||||
// Starts at a minimum power of 20%, goes up to 100% over maxDrawTime
|
||||
_currentPowerMultiplier = Mathf.Lerp(0.2f, 1.0f, Mathf.Clamp01(drawDuration / _maxDrawTime));
|
||||
|
||||
// Tell the animator we've reached max draw so it can play a Shake animation
|
||||
bool isMaxPower = drawDuration >= _maxDrawTime;
|
||||
_animator.SetBool("IsMaxDraw", isMaxPower);
|
||||
|
||||
if (_playerStats != null)
|
||||
{
|
||||
AnimatorStateInfo stateInfo = _animator.GetCurrentAnimatorStateInfo(_aimLayer);
|
||||
|
||||
// Only drain stamina if the string is fully pulled back (in "Drawn" or "Shake" state)
|
||||
if (stateInfo.IsName("Drawn") || stateInfo.IsName("Shake"))
|
||||
{
|
||||
_playerStats.UseStamina(_staminaDrainRate * Time.deltaTime);
|
||||
|
||||
// If stamina reaches 0, force the player to release the arrow
|
||||
if (_playerStats.currentStamina <= 0f)
|
||||
{
|
||||
drawHeld = false; // Force release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release while drawing = fire the arrow
|
||||
@@ -328,6 +379,7 @@ public class BowAimLayerController : MonoBehaviour
|
||||
{
|
||||
_isDrawing = false;
|
||||
_animator.SetBool("IsDrawing", false);
|
||||
_animator.SetBool("IsMaxDraw", false);
|
||||
_animator.SetTrigger("Fire");
|
||||
}
|
||||
}
|
||||
@@ -380,9 +432,18 @@ public class BowAimLayerController : MonoBehaviour
|
||||
if (_loadedArrow == null) return;
|
||||
}
|
||||
|
||||
// 1. Find where the crosshair is looking
|
||||
// 1. Find where the crosshair is looking (or auto-aim if locked on)
|
||||
Vector3 aimPoint;
|
||||
if (_camera != null)
|
||||
|
||||
Synty.AnimationBaseLocomotion.Samples.SamplePlayerAnimationController playerController =
|
||||
GetComponent<Synty.AnimationBaseLocomotion.Samples.SamplePlayerAnimationController>();
|
||||
|
||||
if (playerController != null && playerController.CurrentLockOnTarget != null)
|
||||
{
|
||||
// Auto aim directly at the locked on target's chest!
|
||||
aimPoint = playerController.CurrentLockOnTarget.transform.position + (Vector3.up * 1.2f);
|
||||
}
|
||||
else if (_camera != null)
|
||||
{
|
||||
Ray ray = _camera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0));
|
||||
|
||||
@@ -412,7 +473,7 @@ public class BowAimLayerController : MonoBehaviour
|
||||
|
||||
// 5. Enable the script to start flying!
|
||||
_loadedArrow.enabled = true;
|
||||
_loadedArrow.Initialize(damage, _targetLayers);
|
||||
_loadedArrow.Initialize(damage, _targetLayers, _currentPowerMultiplier);
|
||||
|
||||
// Clear the reference so we can load a new one
|
||||
_loadedArrow = null;
|
||||
|
||||
@@ -14,6 +14,9 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
[Tooltip("Your main camera's transform - used to know where you're looking")]
|
||||
[SerializeField] private Transform _cameraTransform;
|
||||
|
||||
[Tooltip("The player's stats script for stamina usage")]
|
||||
[SerializeField] private PlayerStats _playerStats;
|
||||
|
||||
[Header("Weapon Management")]
|
||||
[Tooltip("The active weapon used for damage and stats.")]
|
||||
[SerializeField] private Weapon _currentWeapon;
|
||||
@@ -54,10 +57,12 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
private float _equipWeight;
|
||||
private int _meleeLayerIndex;
|
||||
|
||||
private Transform _spineBone;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_meleeLayerIndex = _animator.GetLayerIndex(_meleeLayerName);
|
||||
_spineBone = _animator.GetBoneTransform(HumanBodyBones.Spine);
|
||||
|
||||
// If the referenced weapon is a prefab asset (not in the scene), instantiate it
|
||||
if (_currentWeapon != null && !_currentWeapon.gameObject.scene.IsValid())
|
||||
@@ -97,10 +102,55 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
_blockAction.Disable();
|
||||
}
|
||||
|
||||
private bool _isAttacking;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Blend the melee layer weight based on equip state
|
||||
float equipTarget = _isEquipped ? 1f : 0f;
|
||||
// We only want this layer to be active if we are actively attacking, blocking, or equipping.
|
||||
bool isLayerActive = false;
|
||||
_isAttacking = false;
|
||||
|
||||
if (_animator != null)
|
||||
{
|
||||
// Check all layers for the Attack state
|
||||
for (int i = 0; i < _animator.layerCount; i++)
|
||||
{
|
||||
AnimatorStateInfo stateInfo = _animator.GetCurrentAnimatorStateInfo(i);
|
||||
if (stateInfo.IsTag("Attack") ||
|
||||
stateInfo.IsName("Attack") ||
|
||||
stateInfo.IsName("Attack1") ||
|
||||
stateInfo.IsName("Attack2") ||
|
||||
stateInfo.IsName("Attack3") ||
|
||||
stateInfo.IsName("A_MOD_SWD_Attack_03"))
|
||||
{
|
||||
_isAttacking = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_meleeLayerIndex != -1)
|
||||
{
|
||||
AnimatorStateInfo stateInfo = _animator.GetCurrentAnimatorStateInfo(_meleeLayerIndex);
|
||||
|
||||
bool isPlayingCombatAnim = stateInfo.IsName("Equip") ||
|
||||
stateInfo.IsName("Unequip");
|
||||
|
||||
// If attacking on ANY layer, or blocking/equipping, keep the combat state active
|
||||
if (_isPoweringUp || _isBlocking || isPlayingCombatAnim)
|
||||
{
|
||||
isLayerActive = true;
|
||||
}
|
||||
|
||||
// If the attack is actually on the melee layer (old setup), keep weight 1
|
||||
if (stateInfo.IsName("Attack"))
|
||||
{
|
||||
isLayerActive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blend the melee layer weight based on if it's currently active
|
||||
float equipTarget = isLayerActive ? 1f : 0f;
|
||||
_equipWeight = Mathf.MoveTowards(_equipWeight, equipTarget, _equipBlendSpeed * Time.deltaTime);
|
||||
|
||||
if (_meleeLayerIndex != -1)
|
||||
@@ -121,6 +171,12 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
{
|
||||
if (_isEquipped) yield break;
|
||||
|
||||
if (_currentWeapon == null)
|
||||
{
|
||||
Debug.LogWarning("Cannot equip! No melee weapon is assigned.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
_isEquipped = true;
|
||||
|
||||
if (_meleeLayerIndex != -1)
|
||||
@@ -221,6 +277,11 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
if (!_isEquipped) return;
|
||||
_isBlocking = true;
|
||||
|
||||
if (_playerStats != null)
|
||||
{
|
||||
_playerStats.staminaRegenMultiplier = 0.5f; // Half regen while blocking
|
||||
}
|
||||
|
||||
// Slow down to walk speed while blocking
|
||||
_inputReader.onWalkToggled?.Invoke();
|
||||
}
|
||||
@@ -231,6 +292,11 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
{
|
||||
_isBlocking = false;
|
||||
|
||||
if (_playerStats != null)
|
||||
{
|
||||
_playerStats.staminaRegenMultiplier = 1f; // Restore regen rate
|
||||
}
|
||||
|
||||
// Return to normal speed
|
||||
_inputReader.onWalkToggled?.Invoke();
|
||||
}
|
||||
@@ -244,6 +310,14 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
{
|
||||
if (_animator != null)
|
||||
{
|
||||
// Optional: You can check if there's enough stamina to attack here
|
||||
// e.g. if (_playerStats != null && !_playerStats.HasStaminaPercentage(0.1f)) return;
|
||||
|
||||
if (_playerStats != null)
|
||||
{
|
||||
_playerStats.UseStamina(15f); // Use 15 stamina per swing
|
||||
}
|
||||
|
||||
_animator.SetTrigger(_attackHash);
|
||||
}
|
||||
}
|
||||
@@ -256,11 +330,13 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
/// </summary>
|
||||
public void Hit()
|
||||
{
|
||||
Debug.Log("Animation Event 'Hit' triggered!");
|
||||
DealDamage();
|
||||
}
|
||||
|
||||
public void DealDamage()
|
||||
{
|
||||
Debug.Log("DealDamage called!");
|
||||
if (_currentWeapon == null)
|
||||
{
|
||||
Debug.LogWarning("No weapon assigned to deal damage!");
|
||||
@@ -271,17 +347,19 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
|
||||
if (_currentWeapon.hitShape == Weapon.HitShape.Sphere)
|
||||
{
|
||||
hitColliders = Physics.OverlapSphere(_currentWeapon.GetHitCenter(), _currentWeapon.attackRadius, _targetLayers);
|
||||
hitColliders = Physics.OverlapSphere(_currentWeapon.GetHitCenter(), _currentWeapon.attackRadius, _targetLayers, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
else
|
||||
{
|
||||
hitColliders = Physics.OverlapBox(_currentWeapon.GetHitCenter(), _currentWeapon.hitBoxHalfExtents, _currentWeapon.GetHitRotation(), _targetLayers);
|
||||
hitColliders = Physics.OverlapBox(_currentWeapon.GetHitCenter(), _currentWeapon.hitBoxHalfExtents, _currentWeapon.GetHitRotation(), _targetLayers, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
|
||||
foreach (Collider hit in hitColliders)
|
||||
{
|
||||
Debug.Log($"Melee hit {hit.name} for {_currentWeapon.damage} damage!");
|
||||
hit.GetComponent<WorldObject>()?.TakeDamage(_currentWeapon.damage);
|
||||
hit.GetComponent<NPCController>()?.TakeDamage(_currentWeapon.damage);
|
||||
hit.GetComponent<PlayerStats>()?.TakeDamage(_currentWeapon.damage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,11 +367,40 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
{
|
||||
if (_isBlocking)
|
||||
{
|
||||
Debug.Log($"Blocked {amount} damage!");
|
||||
return;
|
||||
if (_playerStats != null)
|
||||
{
|
||||
if (_playerStats.currentStamina >= amount)
|
||||
{
|
||||
Debug.Log($"Blocked {amount} damage! Cost {amount} stamina.");
|
||||
_playerStats.UseStamina(amount);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"Block broken! Took {amount} damage.");
|
||||
_isBlocking = false;
|
||||
_playerStats.staminaRegenMultiplier = 1f;
|
||||
_playerStats.UseStamina(_playerStats.currentStamina); // Drain rest of stamina
|
||||
_playerStats.PauseStaminaRegen(3f); // Pause regen for 3 seconds
|
||||
_inputReader.onWalkToggled?.Invoke(); // Stop walking slow
|
||||
|
||||
// Optionally, play a guard break animation here if you have one
|
||||
// _animator.SetTrigger("GuardBreak");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"Blocked {amount} damage!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"Took {amount} damage!");
|
||||
if (_playerStats != null)
|
||||
{
|
||||
_playerStats.TakeDamage(amount);
|
||||
}
|
||||
|
||||
if (_animator != null)
|
||||
{
|
||||
_animator.SetTrigger(_takeDamageHash);
|
||||
@@ -302,5 +409,28 @@ public class MeleeCombatLayerController : MonoBehaviour
|
||||
|
||||
#endregion
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// If we are not attacking and the equip weight is 0, don't bend the spine
|
||||
if (_spineBone == null || _cameraTransform == null) return;
|
||||
if (_equipWeight <= 0f && !_isAttacking) return;
|
||||
|
||||
// Calculate the camera's pitch (up/down rotation)
|
||||
// We use the camera's forward vector compared to the horizontal plane
|
||||
Vector3 cameraForward = _cameraTransform.forward;
|
||||
Vector3 horizontalForward = new Vector3(cameraForward.x, 0, cameraForward.z).normalized;
|
||||
float pitch = Vector3.SignedAngle(horizontalForward, cameraForward, _cameraTransform.right);
|
||||
|
||||
// Clamp the pitch so we don't break the spine backward
|
||||
pitch = Mathf.Clamp(pitch, -45f, 45f);
|
||||
|
||||
// Apply pitch to the spine bone.
|
||||
// If we are doing a full-body attack, force the blend weight to 1.0!
|
||||
float blendWeight = _isAttacking ? 1.0f : _equipWeight;
|
||||
|
||||
// We rotate around the character's right axis to lean forward/backward
|
||||
_spineBone.rotation = Quaternion.AngleAxis(pitch * blendWeight, transform.right) * _spineBone.rotation;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/NPCController.cs.meta
Normal file
2
Assets/Scripts/NPCController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90f1d1266353035ee9c6f1bc9d99eb12
|
||||
28
Assets/Scripts/NPCData.cs
Normal file
28
Assets/Scripts/NPCData.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(fileName = "New NPC Data", menuName = "RPG/NPC Data")]
|
||||
public class NPCData : ScriptableObject
|
||||
{
|
||||
[Header("Stats")]
|
||||
public float maxHealth = 100f;
|
||||
|
||||
[Header("Combat Settings")]
|
||||
public NPCFaction defaultFaction = NPCFaction.Enemy;
|
||||
public float aggroRadius = 15f;
|
||||
public float attackRadius = 2.0f;
|
||||
public float attackCooldown = 2.5f;
|
||||
|
||||
[Header("Movement")]
|
||||
public float walkSpeed = 2.0f;
|
||||
public float runSpeed = 5.0f;
|
||||
|
||||
[Header("Equipment")]
|
||||
[Tooltip("Base damage dealt if no weapon is equipped.")]
|
||||
public float baseDamage = 15f;
|
||||
|
||||
[Tooltip("The weapon data for damage calculations and hit shape.")]
|
||||
public Weapon weaponData;
|
||||
|
||||
[Tooltip("The visual weapon prefab to spawn in the NPC's hand.")]
|
||||
public GameObject weaponPrefab;
|
||||
}
|
||||
2
Assets/Scripts/NPCData.cs.meta
Normal file
2
Assets/Scripts/NPCData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a0587162fe30716e8a8ff094a387e24
|
||||
141
Assets/Scripts/PlayerStats.cs
Normal file
141
Assets/Scripts/PlayerStats.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
public class PlayerStats : MonoBehaviour
|
||||
{
|
||||
[Header("Health")]
|
||||
public float maxHealth = 100f;
|
||||
public float currentHealth;
|
||||
|
||||
[Header("Mana")]
|
||||
public float maxMana = 100f;
|
||||
public float currentMana;
|
||||
|
||||
[Header("Stamina")]
|
||||
public float maxStamina = 100f;
|
||||
public float currentStamina;
|
||||
public float staminaRegenRate = 15f;
|
||||
public float staminaRegenDelay = 1.5f;
|
||||
|
||||
[Header("Events")]
|
||||
public UnityEvent onDeath;
|
||||
|
||||
private float _lastStaminaUseTime;
|
||||
private bool _isDead;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
currentHealth = maxHealth;
|
||||
currentMana = maxMana;
|
||||
currentStamina = maxStamina;
|
||||
}
|
||||
|
||||
public float sprintStaminaDrainRate = 10f;
|
||||
[Tooltip("Optional: Drag the InputReader here to automatically drain stamina when sprinting")]
|
||||
public Synty.AnimationBaseLocomotion.Samples.InputSystem.InputReader inputReader;
|
||||
|
||||
private bool _isSprinting;
|
||||
|
||||
private float _regenPauseEndTime;
|
||||
[HideInInspector] public float staminaRegenMultiplier = 1f;
|
||||
|
||||
public void PauseStaminaRegen(float seconds)
|
||||
{
|
||||
_regenPauseEndTime = Time.time + seconds;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (inputReader != null)
|
||||
{
|
||||
inputReader.onSprintActivated += HandleSprintStart;
|
||||
inputReader.onSprintDeactivated += HandleSprintStop;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (inputReader != null)
|
||||
{
|
||||
inputReader.onSprintActivated -= HandleSprintStart;
|
||||
inputReader.onSprintDeactivated -= HandleSprintStop;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleSprintStart() => _isSprinting = true;
|
||||
private void HandleSprintStop() => _isSprinting = false;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_isDead) return;
|
||||
|
||||
// Drain stamina if sprinting and moving
|
||||
if (_isSprinting && inputReader != null && inputReader._movementInputDetected)
|
||||
{
|
||||
UseStamina(sprintStaminaDrainRate * Time.deltaTime);
|
||||
|
||||
// If we run out of stamina while sprinting, you might want to force walk here
|
||||
if (currentStamina <= 0)
|
||||
{
|
||||
// Force sprint off via the reader
|
||||
_isSprinting = false;
|
||||
// Note: To perfectly force the Synty controller to stop running,
|
||||
// you would normally call a method on the SamplePlayerAnimationController.
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regenerate stamina if we haven't used it recently and not paused
|
||||
if (Time.time > _lastStaminaUseTime + staminaRegenDelay && Time.time > _regenPauseEndTime)
|
||||
{
|
||||
if (currentStamina < maxStamina)
|
||||
{
|
||||
currentStamina += (staminaRegenRate * staminaRegenMultiplier) * Time.deltaTime;
|
||||
currentStamina = Mathf.Clamp(currentStamina, 0f, maxStamina);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void TakeDamage(float amount)
|
||||
{
|
||||
if (_isDead) return;
|
||||
|
||||
currentHealth -= amount;
|
||||
currentHealth = Mathf.Clamp(currentHealth, 0f, maxHealth);
|
||||
|
||||
if (currentHealth <= 0)
|
||||
{
|
||||
Die();
|
||||
}
|
||||
}
|
||||
|
||||
public void Heal(float amount)
|
||||
{
|
||||
if (_isDead) return;
|
||||
currentHealth += amount;
|
||||
currentHealth = Mathf.Clamp(currentHealth, 0f, maxHealth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to use stamina.
|
||||
/// </summary>
|
||||
public void UseStamina(float amount)
|
||||
{
|
||||
currentStamina -= amount;
|
||||
currentStamina = Mathf.Clamp(currentStamina, 0f, maxStamina);
|
||||
_lastStaminaUseTime = Time.time;
|
||||
}
|
||||
|
||||
public bool HasStaminaPercentage(float percentage)
|
||||
{
|
||||
return currentStamina >= (maxStamina * percentage);
|
||||
}
|
||||
|
||||
private void Die()
|
||||
{
|
||||
_isDead = true;
|
||||
Debug.Log("Player has died!");
|
||||
onDeath?.Invoke();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/PlayerStats.cs.meta
Normal file
2
Assets/Scripts/PlayerStats.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0578dc3754dcf52858001a92de5abd21
|
||||
55
Assets/Scripts/PlayerUIManager.cs
Normal file
55
Assets/Scripts/PlayerUIManager.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class PlayerUIManager : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("Drag the Player (or the GameObject with PlayerStats) here")]
|
||||
public PlayerStats playerStats;
|
||||
|
||||
[Header("UI Bars (Sliders)")]
|
||||
[Tooltip("Assign if you are using Unity UI Sliders for your bars")]
|
||||
public Slider healthBar;
|
||||
public Slider manaBar;
|
||||
public Slider staminaBar;
|
||||
|
||||
[Header("UI Bars (Images - Fill Method)")]
|
||||
[Tooltip("Assign if you are using UI Images with Image Type set to 'Filled'")]
|
||||
public Image healthFill;
|
||||
public Image manaFill;
|
||||
public Image staminaFill;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (playerStats == null)
|
||||
{
|
||||
Debug.LogWarning("PlayerUIManager: PlayerStats reference is missing! Please assign it in the Inspector.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize Slider max values if they are being used
|
||||
if (healthBar != null) healthBar.maxValue = playerStats.maxHealth;
|
||||
if (manaBar != null) manaBar.maxValue = playerStats.maxMana;
|
||||
if (staminaBar != null) staminaBar.maxValue = playerStats.maxStamina;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (playerStats == null) return;
|
||||
|
||||
UpdateBars();
|
||||
}
|
||||
|
||||
private void UpdateBars()
|
||||
{
|
||||
// 1. Update Sliders (if assigned)
|
||||
if (healthBar != null) healthBar.value = playerStats.currentHealth;
|
||||
if (manaBar != null) manaBar.value = playerStats.currentMana;
|
||||
if (staminaBar != null) staminaBar.value = playerStats.currentStamina;
|
||||
|
||||
// 2. Update Image Fills (if assigned)
|
||||
if (healthFill != null) healthFill.fillAmount = playerStats.currentHealth / playerStats.maxHealth;
|
||||
if (manaFill != null) manaFill.fillAmount = playerStats.currentMana / playerStats.maxMana;
|
||||
if (staminaFill != null) staminaFill.fillAmount = playerStats.currentStamina / playerStats.maxStamina;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/PlayerUIManager.cs.meta
Normal file
2
Assets/Scripts/PlayerUIManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 19463b4fe03a27cb780ba334e31f4cd7
|
||||
@@ -17,18 +17,29 @@ public class Projectile : MonoBehaviour
|
||||
public Vector3 loadedPositionOffset = Vector3.zero;
|
||||
public Vector3 loadedRotationOffset = Vector3.zero;
|
||||
|
||||
[Tooltip("How strongly gravity affects the arrow. 1 = standard Unity gravity, 0 = flies perfectly straight.")]
|
||||
public float gravityScale = 1f;
|
||||
|
||||
private float _damage;
|
||||
private LayerMask _targetLayers;
|
||||
private bool _hasHit;
|
||||
private Vector3 _lastPosition;
|
||||
private Vector3 _startPosition;
|
||||
private Vector3 _velocity;
|
||||
|
||||
public void Initialize(float damage, LayerMask targets)
|
||||
public void Initialize(float damage, LayerMask targets, float powerMultiplier)
|
||||
{
|
||||
_damage = damage;
|
||||
// Damage is scaled based on how long the bow was drawn
|
||||
_damage = damage * powerMultiplier;
|
||||
_targetLayers = targets;
|
||||
|
||||
// Initial velocity is scaled by power multiplier
|
||||
_velocity = transform.forward * (speed * powerMultiplier);
|
||||
|
||||
// Destroy the arrow after a few seconds if it flies into the sky
|
||||
Destroy(gameObject, lifetime);
|
||||
|
||||
_startPosition = transform.position;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
@@ -40,14 +51,22 @@ public class Projectile : MonoBehaviour
|
||||
{
|
||||
if (_hasHit) return;
|
||||
|
||||
// Move the projectile forward
|
||||
Vector3 direction = transform.forward;
|
||||
float distance = speed * Time.deltaTime;
|
||||
transform.position += direction * distance;
|
||||
// Apply gravity to velocity over time (this creates the arc!)
|
||||
_velocity += Physics.gravity * gravityScale * Time.deltaTime;
|
||||
|
||||
// Make the arrow physically point in the direction it is travelling
|
||||
if (_velocity.sqrMagnitude > 0.01f)
|
||||
{
|
||||
transform.forward = _velocity.normalized;
|
||||
}
|
||||
|
||||
Vector3 direction = _velocity.normalized;
|
||||
float distance = _velocity.magnitude * Time.deltaTime;
|
||||
transform.position += _velocity * Time.deltaTime;
|
||||
|
||||
// Raycast from the previous position to the new position to detect hits
|
||||
// This prevents fast-moving arrows from passing straight through thin walls or enemies
|
||||
if (Physics.Raycast(_lastPosition, direction, out RaycastHit hit, distance, _targetLayers))
|
||||
// We explicitly Ignore triggers so arrows don't hit the giant Lock-On detection spheres!
|
||||
if (Physics.Raycast(_lastPosition, direction, out RaycastHit hit, distance, _targetLayers, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
HandleHit(hit);
|
||||
}
|
||||
@@ -73,9 +92,12 @@ public class Projectile : MonoBehaviour
|
||||
Destroy(rb);
|
||||
}
|
||||
|
||||
// Deal damage
|
||||
Debug.Log($"Arrow hit {hit.collider.name} for {_damage} damage!");
|
||||
// Deal damage and log distance
|
||||
float distanceFlown = Vector3.Distance(_startPosition, transform.position);
|
||||
Debug.Log($"Arrow hit {hit.collider.name} for {_damage:F1} damage! It flew {distanceFlown:F1} meters.");
|
||||
hit.collider.GetComponent<WorldObject>()?.TakeDamage(_damage);
|
||||
hit.collider.GetComponent<NPCController>()?.TakeDamage(_damage);
|
||||
hit.collider.GetComponent<PlayerStats>()?.TakeDamage(_damage);
|
||||
|
||||
// Optional: Disable collider or script so it doesn't keep checking for hits
|
||||
Collider myCollider = GetComponent<Collider>();
|
||||
|
||||
Reference in New Issue
Block a user