using UnityEngine; /// /// Отзывчивый аркадный FPS-контроллер на CharacterController. /// Move() вызывается в Update — движение плавное на частоте кадров без интерполяции. /// Step offset и slope limit — встроенные в CharacterController. /// Init() вызывается из PlayerFacade только у владельца. /// [RequireComponent(typeof(CharacterController))] public class PlayerMovement : MonoBehaviour { [Header("Speed")] [SerializeField] private float walkSpeed = 8f; [SerializeField] private float sprintSpeed = 12f; [Header("Acceleration")] [SerializeField] private float groundAcceleration = 80f; // как быстро набираем скорость на земле [SerializeField] private float airAcceleration = 30f; // управление в воздухе [SerializeField] private float groundDeceleration = 60f; // трение/торможение при отпускании клавиш [Header("Jump")] [SerializeField] private float jumpHeight = 1.6f; // желаемая высота прыжка в метрах [SerializeField] private float coyoteTime = 0.12f; // окно прыжка после схода с края [SerializeField] private float jumpBufferTime = 0.12f; // окно "запоминания" нажатия прыжка [Header("Gravity")] [SerializeField] private float gravity = -28f; // выше реального -9.81 = более аркадно/отзывчиво [SerializeField] private float fallMultiplier = 1.6f; // падаем тяжелее — меньше "плавучести" [SerializeField] private float lowJumpMultiplier = 2.5f; // отпустил прыжок раньше = короткий прыжок [SerializeField] private float maxFallSpeed = -40f; // ограничение скорости падения [Header("Ground / Steps / Slopes")] [SerializeField] private float stepOffset = 0.4f; // высота ступеньки на которую можно зайти [SerializeField] private float slopeLimit = 50f; // макс. угол склона [SerializeField] private float groundStickForce = -4f; // прижим к земле чтобы не отрывало на спусках [Header("Look")] [SerializeField] private float mouseSensitivity = 2f; [SerializeField] private Transform cameraRoot; [SerializeField] private float minPitch = -89f; [SerializeField] private float maxPitch = 89f; private CharacterController controller; private PlayerHealth playerHealth; private bool isActive; // Горизонтальная скорость (x, z) — храним между кадрами для инерции private Vector3 horizontalVelocity; private float verticalVelocity; // Look private float pitch; // Jump timers private float coyoteCounter; private float jumpBufferCounter; public void Init() { controller = GetComponent(); playerHealth = GetComponent(); // Встроенные возможности CharacterController — задаём из инспектора controller.stepOffset = stepOffset; controller.slopeLimit = slopeLimit; // // Прячем курсор — только у владельца, Init для не-владельца не вызывается // Cursor.lockState = CursorLockMode.Locked; // Cursor.visible = false; isActive = true; } private void Update() { if (!isActive) return; // Esc — освободить курсор (удобно в редакторе) // if (Input.GetKeyDown(KeyCode.Escape)) // { // Cursor.lockState = CursorLockMode.None; // Cursor.visible = true; // } if (IsMovementBlocked()) { // Мёртвый — CharacterController выключен (как коллайдер), двигать нельзя horizontalVelocity = Vector3.zero; verticalVelocity = 0f; return; } HandleLookYaw(); // поворот тела по горизонтали — здесь, камера-ребёнок следует за ним UpdateJumpTimers(); HandleHorizontal(); HandleJumpAndGravity(); // Контроллер могли выключить (смерть) между кадрами — Move на выключенном кинет ошибку if (!controller.enabled) return; // Один общий Move за кадр — и горизонталь, и вертикаль Vector3 velocity = horizontalVelocity + Vector3.up * verticalVelocity; controller.Move(velocity * Time.deltaTime); } private void LateUpdate() { if (!isActive) return; // Наклон камеры (pitch) — после движения, чтобы не было микро-дрожи cameraRoot.localRotation = Quaternion.Euler(pitch, 0f, 0f); } private bool IsMovementBlocked() { return playerHealth != null && playerHealth.isDead.Value; } // ── LOOK ────────────────────────────────────────────── private void HandleLookYaw() { float mouseX = Input.GetAxis("Mouse X") * mouseSensitivity; transform.Rotate(Vector3.up * mouseX); float mouseY = Input.GetAxis("Mouse Y") * mouseSensitivity; pitch = Mathf.Clamp(pitch - mouseY, minPitch, maxPitch); } // ── HORIZONTAL MOVEMENT ─────────────────────────────── private void HandleHorizontal() { Vector3 input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical")); input = Vector3.ClampMagnitude(input, 1f); Vector3 wishDirection = transform.right * input.x + transform.forward * input.z; bool isSprinting = Input.GetKey(KeyCode.LeftShift); float targetSpeed = isSprinting ? sprintSpeed : walkSpeed; bool grounded = controller.isGrounded; if (wishDirection.sqrMagnitude > 0.01f) { // Есть ввод — разгоняемся к целевой скорости float acceleration = grounded ? groundAcceleration : airAcceleration; Vector3 targetVelocity = wishDirection * targetSpeed; horizontalVelocity = Vector3.MoveTowards( horizontalVelocity, targetVelocity, acceleration * Time.deltaTime ); } else if (grounded) { // Нет ввода на земле — тормозим до нуля (трение) horizontalVelocity = Vector3.MoveTowards( horizontalVelocity, Vector3.zero, groundDeceleration * Time.deltaTime ); } // В воздухе без ввода — сохраняем импульс (момент инерции) } // ── JUMP & GRAVITY ──────────────────────────────────── private void UpdateJumpTimers() { if (controller.isGrounded) coyoteCounter = coyoteTime; else coyoteCounter -= Time.deltaTime; if (Input.GetButtonDown("Jump")) jumpBufferCounter = jumpBufferTime; else jumpBufferCounter -= Time.deltaTime; } private void HandleJumpAndGravity() { bool grounded = controller.isGrounded; // Прижим к земле — иначе на спусках и ступеньках вниз отрывает в "полёт" if (grounded && verticalVelocity < 0f) verticalVelocity = groundStickForce; // Прыжок: есть буфер нажатия И есть coyote-окно if (jumpBufferCounter > 0f && coyoteCounter > 0f) { jumpBufferCounter = 0f; coyoteCounter = 0f; // Скорость из желаемой высоты: v = sqrt(2 * h * g) verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity); } ApplyGravity(grounded); } private void ApplyGravity(bool grounded) { if (grounded && verticalVelocity <= 0f) return; // на земле гравитацию не копим (кроме прыжка вверх) float g = gravity; if (verticalVelocity < 0f) { g *= fallMultiplier; // падаем — тяжелее } else if (verticalVelocity > 0f && !Input.GetButton("Jump")) { g *= lowJumpMultiplier; // отпустил прыжок — короткий хоп } verticalVelocity += g * Time.deltaTime; verticalVelocity = Mathf.Max(verticalVelocity, maxFallSpeed); } }