547 lines
18 KiB
C#
547 lines
18 KiB
C#
using UnityEngine;
|
||
using UnityEngine.InputSystem;
|
||
|
||
[RequireComponent(typeof(Rigidbody))]
|
||
[RequireComponent(typeof(CapsuleCollider))]
|
||
public class PlayerController : MonoBehaviour
|
||
{
|
||
[Header("Камера и Мышь")]
|
||
[SerializeField] private Transform cameraPosition;
|
||
[SerializeField] private float mouseSensitivity = 0.1f;
|
||
[SerializeField] private float maxLookAngle = 80f;
|
||
|
||
[Header("Оружие и Стрельба (Hitscan)")]
|
||
[SerializeField] private float damage = 25f;
|
||
[Tooltip("Задержка между выстрелами в секундах (0.1 = 600 выстр/мин)")]
|
||
[SerializeField] private float fireRate = 0.12f;
|
||
[SerializeField] private float weaponRange = 100f;
|
||
[SerializeField] private LayerMask hitLayers = ~0; // По кому попадаем (по умолчанию всё)
|
||
[SerializeField] private bool isAutomatic = true;
|
||
[Tooltip("Сила вертикального пинка камеры при выстреле")]
|
||
[SerializeField] private float recoilKick = 1.2f;
|
||
[SerializeField] private ParticleSystem muzzleFlash;
|
||
[SerializeField] private GameObject impactPrefab; // Префаб искр / дырки от пули
|
||
[SerializeField] private AudioClip shootSound;
|
||
|
||
[Header("Скорости передвижения (м/с)")]
|
||
[SerializeField] private float moveSpeed = 6.5f;
|
||
[SerializeField] private float sprintSpeed = 9.5f;
|
||
[SerializeField] private float crouchSpeed = 3f;
|
||
|
||
[Header("Параметры Подката (Slide)")]
|
||
[SerializeField] private float slideInitialSpeed = 15f;
|
||
[SerializeField] private float slideDuration = 0.85f;
|
||
[SerializeField] private float slideFriction = 2.8f;
|
||
[SerializeField] private float slideCameraTiltAngle = 4f;
|
||
[SerializeField] private float slideCooldown = 0.35f;
|
||
|
||
[Header("Физика CS / Source (Bunnyhop & Strafe)")]
|
||
[SerializeField] private float groundFriction = 5f;
|
||
[SerializeField] private float groundAcceleration = 10f;
|
||
[SerializeField] private float airAcceleration = 80f;
|
||
[SerializeField] private float airCap = 2.0f;
|
||
[SerializeField] private bool showSpeedometer = true;
|
||
|
||
[Header("Гравитация и Прыжок")]
|
||
[SerializeField] private float jumpHeight = 1.35f;
|
||
[SerializeField] private float gravity = -25f;
|
||
[SerializeField] private float fallMultiplier = 1.8f;
|
||
[SerializeField] private LayerMask groundLayer;
|
||
[SerializeField] private float groundCheckDistance = 0.12f;
|
||
[SerializeField] private float jumpBufferTime = 0.12f;
|
||
|
||
[Header("Параметры приседания")]
|
||
[SerializeField] private float standingHeight = 2f;
|
||
[SerializeField] private float crouchingHeight = 1.05f;
|
||
[SerializeField] private float standingCameraY = 1.6f;
|
||
[SerializeField] private float crouchingCameraY = 0.8f;
|
||
[SerializeField] private float crouchTransitionSpeed = 15f;
|
||
|
||
[Header("Тряска Камеры (Headbob)")]
|
||
public bool enableHeadBob = true;
|
||
[SerializeField] private float walkBobSpeed = 12f;
|
||
[SerializeField] private float walkBobAmount = 0.04f;
|
||
[SerializeField] private float sprintBobSpeed = 15f;
|
||
[SerializeField] private float sprintBobAmount = 0.07f;
|
||
[SerializeField] private float impactRecoverySpeed = 6f;
|
||
|
||
[Header("Звуки (Audio)")]
|
||
[SerializeField] private AudioSource audioSource;
|
||
[SerializeField] private AudioClip[] footstepSounds;
|
||
[SerializeField] private AudioClip jumpSound;
|
||
[SerializeField] private AudioClip landSound;
|
||
[SerializeField] private AudioClip slideSound;
|
||
|
||
// Внутренние ссылки и ввод
|
||
private PlayerControls controls;
|
||
private Rigidbody rb;
|
||
private CapsuleCollider col;
|
||
|
||
private Vector2 moveInput;
|
||
private Vector2 lookInput;
|
||
private Vector3 horizontalVelocity = Vector3.zero;
|
||
private float verticalVelocity = 0f;
|
||
|
||
// Стрельба
|
||
private float shootTimer = 0f;
|
||
private bool isShootingInput = false;
|
||
|
||
// Камера
|
||
private float cameraPitch = 0f;
|
||
private float yRotation = 0f;
|
||
private float currentCameraTilt = 0f;
|
||
|
||
// Состояния
|
||
private bool isGrounded;
|
||
private bool wasGrounded;
|
||
private bool isSprinting = false;
|
||
private bool isCrouching = false;
|
||
private float jumpBufferTimer = 0f;
|
||
|
||
// Подкат
|
||
private bool isSliding = false;
|
||
private float slideTimer = 0f;
|
||
private float slideCurrentSpeed = 0f;
|
||
private Vector3 slideDirection = Vector3.zero;
|
||
private float slideCooldownTimer = 0f;
|
||
|
||
// Headbob & Impact
|
||
private float baseCameraY;
|
||
private float bobTimer = 0f;
|
||
private Vector3 bobOffset = Vector3.zero;
|
||
private float currentImpactOffset = 0f;
|
||
private bool stepTaken = false;
|
||
|
||
private void Awake()
|
||
{
|
||
rb = GetComponent<Rigidbody>();
|
||
col = GetComponent<CapsuleCollider>();
|
||
mouseSensitivity = PlayerPrefs.GetFloat("MouseSensitivity", mouseSensitivity);
|
||
rb.useGravity = false;
|
||
rb.freezeRotation = true;
|
||
rb.interpolation = RigidbodyInterpolation.None;
|
||
|
||
Cursor.lockState = CursorLockMode.Locked;
|
||
Cursor.visible = false;
|
||
|
||
if (cameraPosition == null && Camera.main != null)
|
||
cameraPosition = Camera.main.transform;
|
||
|
||
baseCameraY = standingCameraY;
|
||
yRotation = transform.eulerAngles.y;
|
||
|
||
if (audioSource == null)
|
||
{
|
||
audioSource = GetComponent<AudioSource>();
|
||
if (audioSource == null) audioSource = gameObject.AddComponent<AudioSource>();
|
||
}
|
||
|
||
controls = new PlayerControls();
|
||
|
||
// Спринт
|
||
controls.Player.Sprint.started += ctx => isSprinting = true;
|
||
controls.Player.Sprint.canceled += ctx => isSprinting = false;
|
||
|
||
// Присед и Подкат
|
||
controls.Player.Crouch.started += ctx => OnCrouchPressed();
|
||
controls.Player.Crouch.canceled += ctx => OnCrouchReleased();
|
||
|
||
// Прыжок на Пробел
|
||
controls.Player.Jump.performed += ctx => QueueJump();
|
||
|
||
// Стрельба (ЛКМ) через Input Actions
|
||
controls.Player.Attack.started += ctx => isShootingInput = true;
|
||
controls.Player.Attack.canceled += ctx => isShootingInput = false;
|
||
}
|
||
|
||
private void OnEnable() => controls.Player.Enable();
|
||
private void OnDisable() => controls.Player.Disable();
|
||
|
||
private void Update()
|
||
{
|
||
moveInput = controls.Player.Move.ReadValue<Vector2>();
|
||
lookInput = controls.Player.Look.ReadValue<Vector2>();
|
||
|
||
if (jumpBufferTimer > 0f)
|
||
jumpBufferTimer -= Time.deltaTime;
|
||
|
||
if (slideCooldownTimer > 0f)
|
||
slideCooldownTimer -= Time.deltaTime;
|
||
|
||
// Прыжок на колесико мыши
|
||
if (Mouse.current != null)
|
||
{
|
||
float scrollY = Mouse.current.scroll.y.ReadValue();
|
||
if (Mathf.Abs(scrollY) > 0.1f)
|
||
{
|
||
QueueJump();
|
||
}
|
||
}
|
||
|
||
HandleShooting();
|
||
HandleMouseLook();
|
||
HandleCameraAndBobbing();
|
||
}
|
||
|
||
private void FixedUpdate()
|
||
{
|
||
CheckGrounded();
|
||
CalculateCustomGravity();
|
||
|
||
if (jumpBufferTimer > 0f && isGrounded)
|
||
{
|
||
ExecuteJump();
|
||
}
|
||
|
||
ApplyMovement();
|
||
|
||
rb.linearVelocity = new Vector3(horizontalVelocity.x, verticalVelocity, horizontalVelocity.z);
|
||
wasGrounded = isGrounded;
|
||
}
|
||
|
||
#region Механика Стрельбы (Weapons)
|
||
|
||
private void HandleShooting()
|
||
{
|
||
if (shootTimer > 0f)
|
||
shootTimer -= Time.deltaTime;
|
||
|
||
// Проверяем ввод: либо через Input Action, либо напрямую через Mouse (для надежности)
|
||
bool shootPressed = isShootingInput || (Mouse.current != null && (isAutomatic ? Mouse.current.leftButton.isPressed : Mouse.current.leftButton.wasPressedThisFrame));
|
||
|
||
if (shootPressed && shootTimer <= 0f)
|
||
{
|
||
Shoot();
|
||
}
|
||
}
|
||
|
||
private void Shoot()
|
||
{
|
||
shootTimer = fireRate;
|
||
|
||
// 1. Звук выстрела
|
||
if (shootSound != null)
|
||
audioSource.PlayOneShot(shootSound, 0.8f);
|
||
|
||
// 2. Вспышка из ствола
|
||
if (muzzleFlash != null)
|
||
muzzleFlash.Play();
|
||
|
||
// 3. Отдача (вздергивание прицела вверх)
|
||
cameraPitch -= recoilKick;
|
||
|
||
// 4. Рейкаст строго из центра камеры
|
||
Ray ray = new Ray(cameraPosition.position, cameraPosition.forward);
|
||
if (Physics.Raycast(ray, out RaycastHit hit, weaponRange, hitLayers, QueryTriggerInteraction.Ignore))
|
||
{
|
||
// Нанесение урона врагу
|
||
if (hit.collider.TryGetComponent<IDamageable>(out var damageable))
|
||
{
|
||
damageable.TakeDamage(damage);
|
||
}
|
||
// Если попали в физ. объект — отталкиваем его импульсом пули
|
||
else if (hit.rigidbody != null)
|
||
{
|
||
hit.rigidbody.AddForceAtPosition(-hit.normal * (damage * 10f), hit.point, ForceMode.Impulse);
|
||
}
|
||
|
||
// Спавн декали / эффекта удара пули
|
||
if (impactPrefab != null)
|
||
{
|
||
GameObject impact = Instantiate(impactPrefab, hit.point + (hit.normal * 0.001f), Quaternion.LookRotation(hit.normal));
|
||
Destroy(impact, 3f);
|
||
}
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
private void QueueJump()
|
||
{
|
||
jumpBufferTimer = jumpBufferTime;
|
||
}
|
||
|
||
#region Логика Подката (Slide)
|
||
|
||
private void OnCrouchPressed()
|
||
{
|
||
if (isSprinting && isGrounded && moveInput.y > 0.2f && slideCooldownTimer <= 0f)
|
||
{
|
||
StartSlide();
|
||
}
|
||
else
|
||
{
|
||
isCrouching = true;
|
||
}
|
||
}
|
||
|
||
private void OnCrouchReleased()
|
||
{
|
||
isCrouching = false;
|
||
if (isSliding)
|
||
{
|
||
StopSlide();
|
||
}
|
||
}
|
||
|
||
private void StartSlide()
|
||
{
|
||
isSliding = true;
|
||
slideTimer = slideDuration;
|
||
slideCurrentSpeed = slideInitialSpeed;
|
||
|
||
slideDirection = (transform.forward * moveInput.y + transform.right * moveInput.x).normalized;
|
||
if (slideDirection.sqrMagnitude < 0.01f) slideDirection = transform.forward;
|
||
|
||
if (slideSound != null)
|
||
audioSource.PlayOneShot(slideSound, 0.7f);
|
||
}
|
||
|
||
private void StopSlide()
|
||
{
|
||
isSliding = false;
|
||
slideCooldownTimer = slideCooldown;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Физика Движения и CS Баннихоп
|
||
|
||
private void ApplyMovement()
|
||
{
|
||
if (isSliding)
|
||
{
|
||
slideCurrentSpeed = Mathf.Lerp(slideCurrentSpeed, crouchSpeed, Time.fixedDeltaTime * slideFriction);
|
||
slideTimer -= Time.fixedDeltaTime;
|
||
|
||
Vector3 steerDir = (transform.forward * moveInput.y + transform.right * moveInput.x).normalized;
|
||
if (steerDir.sqrMagnitude > 0.01f)
|
||
slideDirection = Vector3.Slerp(slideDirection, steerDir, Time.fixedDeltaTime * 2.5f);
|
||
|
||
horizontalVelocity = slideDirection * slideCurrentSpeed;
|
||
|
||
if (slideTimer <= 0f || slideCurrentSpeed <= crouchSpeed + 0.5f)
|
||
{
|
||
StopSlide();
|
||
isCrouching = controls.Player.Crouch.IsPressed();
|
||
}
|
||
return;
|
||
}
|
||
|
||
Vector3 wishDir = (transform.forward * moveInput.y + transform.right * moveInput.x);
|
||
wishDir.y = 0f;
|
||
wishDir.Normalize();
|
||
|
||
float targetSpeed = isCrouching ? crouchSpeed : (isSprinting ? sprintSpeed : moveSpeed);
|
||
|
||
if (isGrounded)
|
||
{
|
||
if (jumpBufferTimer <= 0f)
|
||
{
|
||
ApplyGroundFriction();
|
||
}
|
||
|
||
Accelerate(wishDir, targetSpeed, groundAcceleration);
|
||
}
|
||
else
|
||
{
|
||
AirAccelerate(wishDir, targetSpeed, airAcceleration, airCap);
|
||
}
|
||
}
|
||
|
||
private void ApplyGroundFriction()
|
||
{
|
||
float speed = horizontalVelocity.magnitude;
|
||
if (speed < 0.0001f)
|
||
{
|
||
horizontalVelocity = Vector3.zero;
|
||
return;
|
||
}
|
||
|
||
float drop = speed * groundFriction * Time.fixedDeltaTime;
|
||
float newSpeed = Mathf.Max(speed - drop, 0f);
|
||
horizontalVelocity *= (newSpeed / speed);
|
||
}
|
||
|
||
private void Accelerate(Vector3 wishDir, float wishSpeed, float accel)
|
||
{
|
||
float currentSpeed = Vector3.Dot(horizontalVelocity, wishDir);
|
||
float addSpeed = wishSpeed - currentSpeed;
|
||
|
||
if (addSpeed <= 0f) return;
|
||
|
||
float accelSpeed = accel * wishSpeed * Time.fixedDeltaTime;
|
||
accelSpeed = Mathf.Min(accelSpeed, addSpeed);
|
||
|
||
horizontalVelocity += wishDir * accelSpeed;
|
||
}
|
||
|
||
private void AirAccelerate(Vector3 wishDir, float wishSpeed, float accel, float cap)
|
||
{
|
||
float wishSpeedClamped = Mathf.Min(wishSpeed, cap);
|
||
float currentSpeedInWishDir = Vector3.Dot(horizontalVelocity, wishDir);
|
||
float addSpeed = wishSpeedClamped - currentSpeedInWishDir;
|
||
|
||
if (addSpeed <= 0f) return;
|
||
|
||
float accelSpeed = accel * wishSpeed * Time.fixedDeltaTime;
|
||
accelSpeed = Mathf.Min(accelSpeed, addSpeed);
|
||
|
||
horizontalVelocity += wishDir * accelSpeed;
|
||
}
|
||
|
||
private void ExecuteJump()
|
||
{
|
||
if (isSliding)
|
||
{
|
||
horizontalVelocity = slideDirection * slideCurrentSpeed;
|
||
StopSlide();
|
||
}
|
||
|
||
verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
|
||
jumpBufferTimer = 0f;
|
||
|
||
if (enableHeadBob)
|
||
currentImpactOffset = 0.1f;
|
||
|
||
if (jumpSound != null)
|
||
audioSource.PlayOneShot(jumpSound, 0.7f);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Камера, Земля и Аудио
|
||
|
||
private void CheckGrounded()
|
||
{
|
||
float checkRadius = col.radius * 0.85f;
|
||
float bottomOffset = (col.height * 0.5f) - checkRadius;
|
||
Vector3 rayOrigin = transform.position + col.center - (Vector3.up * bottomOffset) + (Vector3.up * 0.05f);
|
||
|
||
isGrounded = Physics.SphereCast(
|
||
rayOrigin, checkRadius, Vector3.down, out _, groundCheckDistance + 0.05f, groundLayer, QueryTriggerInteraction.Ignore
|
||
);
|
||
|
||
if (!wasGrounded && isGrounded)
|
||
{
|
||
if (landSound != null) audioSource.PlayOneShot(landSound, 0.5f);
|
||
}
|
||
|
||
if (isGrounded && jumpBufferTimer <= 0f)
|
||
{
|
||
if (rb.linearVelocity.magnitude > 0.01f && horizontalVelocity == Vector3.zero)
|
||
horizontalVelocity = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
|
||
}
|
||
}
|
||
|
||
private void CalculateCustomGravity()
|
||
{
|
||
if (isGrounded && verticalVelocity < 0f)
|
||
{
|
||
verticalVelocity = -2f;
|
||
}
|
||
else
|
||
{
|
||
float currentGravity = (verticalVelocity < 0f) ? (gravity * fallMultiplier) : gravity;
|
||
verticalVelocity += currentGravity * Time.fixedDeltaTime;
|
||
}
|
||
}
|
||
|
||
private void HandleMouseLook()
|
||
{
|
||
cameraPitch -= lookInput.y * mouseSensitivity;
|
||
cameraPitch = Mathf.Clamp(cameraPitch, -maxLookAngle, maxLookAngle);
|
||
|
||
float targetTilt = isSliding ? -slideCameraTiltAngle : 0f;
|
||
currentCameraTilt = Mathf.Lerp(currentCameraTilt, targetTilt, Time.deltaTime * 10f);
|
||
|
||
cameraPosition.localRotation = Quaternion.Euler(cameraPitch, 0f, currentCameraTilt);
|
||
|
||
yRotation += lookInput.x * mouseSensitivity;
|
||
transform.rotation = Quaternion.Euler(0f, yRotation, 0f);
|
||
}
|
||
|
||
private void HandleCameraAndBobbing()
|
||
{
|
||
bool isLowered = isCrouching || isSliding;
|
||
float targetHeight = isLowered ? crouchingHeight : standingHeight;
|
||
float targetCamY = isLowered ? crouchingCameraY : standingCameraY;
|
||
|
||
col.height = Mathf.Lerp(col.height, targetHeight, Time.deltaTime * crouchTransitionSpeed);
|
||
col.center = new Vector3(col.center.x, col.height * 0.5f, col.center.z);
|
||
baseCameraY = Mathf.Lerp(baseCameraY, targetCamY, Time.deltaTime * crouchTransitionSpeed);
|
||
|
||
bool isMoving = moveInput.sqrMagnitude > 0.01f && isGrounded && !isSliding;
|
||
|
||
if (isMoving && enableHeadBob)
|
||
{
|
||
float speed = isSprinting ? sprintBobSpeed : walkBobSpeed;
|
||
float amount = isSprinting ? sprintBobAmount : walkBobAmount;
|
||
|
||
bobTimer += Time.deltaTime * speed;
|
||
bobOffset.x = Mathf.Cos(bobTimer * 0.5f) * amount;
|
||
bobOffset.y = Mathf.Sin(bobTimer) * amount;
|
||
|
||
if (Mathf.Sin(bobTimer) <= -0.9f && !stepTaken)
|
||
{
|
||
PlayFootstep();
|
||
stepTaken = true;
|
||
}
|
||
else if (Mathf.Sin(bobTimer) > -0.2f)
|
||
{
|
||
stepTaken = false;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
bobTimer = 0f;
|
||
stepTaken = false;
|
||
bobOffset = Vector3.Lerp(bobOffset, Vector3.zero, Time.deltaTime * 10f);
|
||
}
|
||
|
||
currentImpactOffset = Mathf.Lerp(currentImpactOffset, 0f, Time.deltaTime * impactRecoverySpeed);
|
||
|
||
cameraPosition.localPosition = new Vector3(
|
||
bobOffset.x,
|
||
baseCameraY + bobOffset.y - currentImpactOffset,
|
||
0f
|
||
);
|
||
}
|
||
|
||
public void SetSensitivity(float newSens)
|
||
{
|
||
mouseSensitivity = newSens;
|
||
}
|
||
|
||
private void PlayFootstep()
|
||
{
|
||
if (footstepSounds == null || footstepSounds.Length == 0) return;
|
||
int randomIndex = Random.Range(0, footstepSounds.Length);
|
||
audioSource.pitch = Random.Range(0.9f, 1.1f);
|
||
audioSource.PlayOneShot(footstepSounds[randomIndex], isSprinting ? 0.6f : 0.4f);
|
||
}
|
||
|
||
#endregion
|
||
|
||
private void OnGUI()
|
||
{
|
||
if (!showSpeedometer) return;
|
||
|
||
float speed = horizontalVelocity.magnitude;
|
||
float csUnits = speed * 38.5f;
|
||
|
||
GUIStyle style = new GUIStyle();
|
||
style.fontSize = 24;
|
||
style.normal.textColor = isSliding ? Color.yellow : (isGrounded ? Color.white : Color.green);
|
||
style.fontStyle = FontStyle.Bold;
|
||
|
||
string state = isSliding ? " [SLIDE]" : (!isGrounded ? " [AIR]" : "");
|
||
GUI.Label(new Rect(30, 30, 500, 50), $"Speed: {speed:F1} m/s ({csUnits:F0} u/s){state}", style);
|
||
}
|
||
}
|
||
|
||
// Интерфейс для любых целей/врагов, принимающих урон
|
||
public interface IDamageable
|
||
{
|
||
void TakeDamage(float amount);
|
||
} |