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

@@ -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;

View File

@@ -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;
}
}

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 90f1d1266353035ee9c6f1bc9d99eb12

28
Assets/Scripts/NPCData.cs Normal file
View 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;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a0587162fe30716e8a8ff094a387e24

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0578dc3754dcf52858001a92de5abd21

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 19463b4fe03a27cb780ba334e31f4cd7

View File

@@ -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>();