491 lines
16 KiB
C#
491 lines
16 KiB
C#
|
|
using System;
|
||
|
|
using UnityEngine;
|
||
|
|
using UnityEngine.Events;
|
||
|
|
using UnityEngine.InputSystem;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// First-person player controller using Unity's New Input System.
|
||
|
|
/// Features:
|
||
|
|
/// - Movement (WASD / Left Stick)
|
||
|
|
/// - Look around (Mouse Delta / Right Stick)
|
||
|
|
/// - Sprint (Left Shift)
|
||
|
|
/// - Crouch (Left Ctrl)
|
||
|
|
/// - Interact (Left Click)
|
||
|
|
/// - Menu Toggle (Right Click)
|
||
|
|
/// </summary>
|
||
|
|
[RequireComponent(typeof(CharacterController))]
|
||
|
|
public class PlayerController : MonoBehaviour
|
||
|
|
{
|
||
|
|
[Header("References")]
|
||
|
|
[Tooltip("First-person camera transform. If unassigned, will search for a Camera in children.")]
|
||
|
|
[SerializeField] private Transform playerCamera;
|
||
|
|
[Tooltip("Optional Menu UI GameObject to toggle with Right Click.")]
|
||
|
|
[SerializeField] private GameObject menuUI;
|
||
|
|
|
||
|
|
[Header("Movement Settings")]
|
||
|
|
[SerializeField] private float walkSpeed = 4.5f;
|
||
|
|
[SerializeField] private float sprintSpeed = 7.5f;
|
||
|
|
[SerializeField] private float crouchSpeed = 2.2f;
|
||
|
|
[SerializeField] private float acceleration = 12f;
|
||
|
|
[SerializeField] private float gravity = -15f;
|
||
|
|
|
||
|
|
[Header("Crouch Settings")]
|
||
|
|
[SerializeField] private float standingHeight = 2.0f;
|
||
|
|
[SerializeField] private float crouchingHeight = 1.0f;
|
||
|
|
[SerializeField] private float crouchTransitionSpeed = 10f;
|
||
|
|
[SerializeField] private Vector3 standingCameraPosition = new Vector3(0, 0.75f, 0);
|
||
|
|
[SerializeField] private Vector3 crouchingCameraPosition = new Vector3(0, 0.1f, 0);
|
||
|
|
[SerializeField] private LayerMask ceilingObstructionMask = ~0;
|
||
|
|
|
||
|
|
[Header("Look Settings")]
|
||
|
|
[SerializeField] private float mouseSensitivityX = 0.12f;
|
||
|
|
[SerializeField] private float mouseSensitivityY = 0.12f;
|
||
|
|
[SerializeField] private float upperLookLimit = 89f;
|
||
|
|
[SerializeField] private float lowerLookLimit = -89f;
|
||
|
|
[SerializeField] private bool invertY = false;
|
||
|
|
|
||
|
|
[Header("Interaction Settings")]
|
||
|
|
[Tooltip("Max distance for interacting with objects.")]
|
||
|
|
[SerializeField] private float interactionDistance = 3.0f;
|
||
|
|
[SerializeField] private LayerMask interactionLayerMask = ~0;
|
||
|
|
[SerializeField] private QueryTriggerInteraction triggerInteraction = QueryTriggerInteraction.Collide;
|
||
|
|
|
||
|
|
[Header("Events")]
|
||
|
|
[Tooltip("Event fired when the menu is opened (true) or closed (false).")]
|
||
|
|
[SerializeField] private UnityEvent<bool> onMenuToggled;
|
||
|
|
[Tooltip("Event fired when looking at or looking away from an interactable (passes prompt string or null).")]
|
||
|
|
[SerializeField] private UnityEvent<string> onHoverInteractableChanged;
|
||
|
|
|
||
|
|
[Header("Input Actions (Optional - Auto-configured if empty)")]
|
||
|
|
[SerializeField] private InputActionProperty moveAction;
|
||
|
|
[SerializeField] private InputActionProperty lookAction;
|
||
|
|
[SerializeField] private InputActionProperty sprintAction;
|
||
|
|
[SerializeField] private InputActionProperty crouchAction;
|
||
|
|
[SerializeField] private InputActionProperty interactAction;
|
||
|
|
[SerializeField] private InputActionProperty menuAction;
|
||
|
|
|
||
|
|
// Components & Runtime State
|
||
|
|
private CharacterController characterController;
|
||
|
|
private Vector2 moveInput;
|
||
|
|
private Vector2 lookInput;
|
||
|
|
private Vector3 currentVelocity;
|
||
|
|
private float verticalVelocity;
|
||
|
|
private float cameraPitch = 0f;
|
||
|
|
private bool isSprinting;
|
||
|
|
private bool isCrouching;
|
||
|
|
private bool isMenuOpen;
|
||
|
|
private IInteractable currentHoveredInteractable;
|
||
|
|
|
||
|
|
// Fallback Input Actions (used if InputActionProperties are not assigned in Inspector)
|
||
|
|
private InputAction internalMoveAction;
|
||
|
|
private InputAction internalLookAction;
|
||
|
|
private InputAction internalSprintAction;
|
||
|
|
private InputAction internalCrouchAction;
|
||
|
|
private InputAction internalInteractAction;
|
||
|
|
private InputAction internalMenuAction;
|
||
|
|
|
||
|
|
public bool IsMenuOpen => isMenuOpen;
|
||
|
|
public bool IsCrouching => isCrouching;
|
||
|
|
public bool IsSprinting => isSprinting;
|
||
|
|
public IInteractable CurrentHoveredInteractable => currentHoveredInteractable;
|
||
|
|
|
||
|
|
private void Awake()
|
||
|
|
{
|
||
|
|
characterController = GetComponent<CharacterController>();
|
||
|
|
|
||
|
|
if (playerCamera == null)
|
||
|
|
{
|
||
|
|
Camera cam = GetComponentInChildren<Camera>();
|
||
|
|
if (cam != null)
|
||
|
|
{
|
||
|
|
playerCamera = cam.transform;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
Debug.LogWarning("[PlayerController] No camera found in children! Please assign Player Camera in Inspector.", this);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
SetupInputs();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Start()
|
||
|
|
{
|
||
|
|
SetCursorState(true);
|
||
|
|
|
||
|
|
if (menuUI != null)
|
||
|
|
{
|
||
|
|
menuUI.SetActive(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnEnable()
|
||
|
|
{
|
||
|
|
EnableInputs();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnDisable()
|
||
|
|
{
|
||
|
|
DisableInputs();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Update()
|
||
|
|
{
|
||
|
|
if (isMenuOpen)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
HandleInputReading();
|
||
|
|
HandleLook();
|
||
|
|
HandleCrouch();
|
||
|
|
HandleMovement();
|
||
|
|
HandleInteractionDetection();
|
||
|
|
}
|
||
|
|
|
||
|
|
#region Input Setup & Management
|
||
|
|
|
||
|
|
private void SetupInputs()
|
||
|
|
{
|
||
|
|
// Move Action
|
||
|
|
if (moveAction.action == null)
|
||
|
|
{
|
||
|
|
internalMoveAction = new InputAction("Move", InputActionType.Value);
|
||
|
|
internalMoveAction.AddCompositeBinding("2DVector")
|
||
|
|
.With("Up", "<Keyboard>/w")
|
||
|
|
.With("Down", "<Keyboard>/s")
|
||
|
|
.With("Left", "<Keyboard>/a")
|
||
|
|
.With("Right", "<Keyboard>/d")
|
||
|
|
.With("Up", "<Keyboard>/upArrow")
|
||
|
|
.With("Down", "<Keyboard>/downArrow")
|
||
|
|
.With("Left", "<Keyboard>/leftArrow")
|
||
|
|
.With("Right", "<Keyboard>/rightArrow");
|
||
|
|
internalMoveAction.AddBinding("<Gamepad>/leftStick");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Look Action
|
||
|
|
if (lookAction.action == null)
|
||
|
|
{
|
||
|
|
internalLookAction = new InputAction("Look", InputActionType.Value);
|
||
|
|
internalLookAction.AddBinding("<Mouse>/delta");
|
||
|
|
internalLookAction.AddBinding("<Gamepad>/rightStick");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sprint Action (Left Shift)
|
||
|
|
if (sprintAction.action == null)
|
||
|
|
{
|
||
|
|
internalSprintAction = new InputAction("Sprint", InputActionType.Button);
|
||
|
|
internalSprintAction.AddBinding("<Keyboard>/leftShift");
|
||
|
|
internalSprintAction.AddBinding("<Gamepad>/leftStickPress");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Crouch Action (Left Ctrl)
|
||
|
|
if (crouchAction.action == null)
|
||
|
|
{
|
||
|
|
internalCrouchAction = new InputAction("Crouch", InputActionType.Button);
|
||
|
|
internalCrouchAction.AddBinding("<Keyboard>/leftCtrl");
|
||
|
|
internalCrouchAction.AddBinding("<Keyboard>/c");
|
||
|
|
internalCrouchAction.AddBinding("<Gamepad>/buttonEast");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Interact Action (Left Click)
|
||
|
|
if (interactAction.action == null)
|
||
|
|
{
|
||
|
|
internalInteractAction = new InputAction("Interact", InputActionType.Button);
|
||
|
|
internalInteractAction.AddBinding("<Mouse>/leftButton");
|
||
|
|
internalInteractAction.AddBinding("<Gamepad>/buttonSouth");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Menu Action (Right Click)
|
||
|
|
if (menuAction.action == null)
|
||
|
|
{
|
||
|
|
internalMenuAction = new InputAction("Menu", InputActionType.Button);
|
||
|
|
internalMenuAction.AddBinding("<Mouse>/rightButton");
|
||
|
|
internalMenuAction.AddBinding("<Keyboard>/escape");
|
||
|
|
internalMenuAction.AddBinding("<Gamepad>/start");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void EnableInputs()
|
||
|
|
{
|
||
|
|
GetAction(moveAction, internalMoveAction)?.Enable();
|
||
|
|
GetAction(lookAction, internalLookAction)?.Enable();
|
||
|
|
GetAction(sprintAction, internalSprintAction)?.Enable();
|
||
|
|
GetAction(crouchAction, internalCrouchAction)?.Enable();
|
||
|
|
|
||
|
|
InputAction interact = GetAction(interactAction, internalInteractAction);
|
||
|
|
if (interact != null)
|
||
|
|
{
|
||
|
|
interact.performed += OnInteractPerformed;
|
||
|
|
interact.Enable();
|
||
|
|
}
|
||
|
|
|
||
|
|
InputAction menu = GetAction(menuAction, internalMenuAction);
|
||
|
|
if (menu != null)
|
||
|
|
{
|
||
|
|
menu.performed += OnMenuPerformed;
|
||
|
|
menu.Enable();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void DisableInputs()
|
||
|
|
{
|
||
|
|
GetAction(moveAction, internalMoveAction)?.Disable();
|
||
|
|
GetAction(lookAction, internalLookAction)?.Disable();
|
||
|
|
GetAction(sprintAction, internalSprintAction)?.Disable();
|
||
|
|
GetAction(crouchAction, internalCrouchAction)?.Disable();
|
||
|
|
|
||
|
|
InputAction interact = GetAction(interactAction, internalInteractAction);
|
||
|
|
if (interact != null)
|
||
|
|
{
|
||
|
|
interact.performed -= OnInteractPerformed;
|
||
|
|
interact.Disable();
|
||
|
|
}
|
||
|
|
|
||
|
|
InputAction menu = GetAction(menuAction, internalMenuAction);
|
||
|
|
if (menu != null)
|
||
|
|
{
|
||
|
|
menu.performed -= OnMenuPerformed;
|
||
|
|
menu.Disable();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private InputAction GetAction(InputActionProperty property, InputAction internalAction)
|
||
|
|
{
|
||
|
|
return property.action ?? internalAction;
|
||
|
|
}
|
||
|
|
|
||
|
|
private void HandleInputReading()
|
||
|
|
{
|
||
|
|
InputAction move = GetAction(moveAction, internalMoveAction);
|
||
|
|
moveInput = move != null ? move.ReadValue<Vector2>() : Vector2.zero;
|
||
|
|
|
||
|
|
InputAction look = GetAction(lookAction, internalLookAction);
|
||
|
|
lookInput = look != null ? look.ReadValue<Vector2>() : Vector2.zero;
|
||
|
|
|
||
|
|
InputAction sprint = GetAction(sprintAction, internalSprintAction);
|
||
|
|
isSprinting = sprint != null && sprint.IsPressed();
|
||
|
|
|
||
|
|
InputAction crouch = GetAction(crouchAction, internalCrouchAction);
|
||
|
|
bool crouchRequested = crouch != null && crouch.IsPressed();
|
||
|
|
|
||
|
|
if (crouchRequested)
|
||
|
|
{
|
||
|
|
isCrouching = true;
|
||
|
|
}
|
||
|
|
else if (isCrouching)
|
||
|
|
{
|
||
|
|
// Only stand up if there's no ceiling blocking overhead
|
||
|
|
if (CanStandUp())
|
||
|
|
{
|
||
|
|
isCrouching = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Movement & Gravity
|
||
|
|
|
||
|
|
private void HandleMovement()
|
||
|
|
{
|
||
|
|
if (characterController == null) return;
|
||
|
|
|
||
|
|
// Grounding & Gravity
|
||
|
|
if (characterController.isGrounded)
|
||
|
|
{
|
||
|
|
if (verticalVelocity < 0f)
|
||
|
|
{
|
||
|
|
verticalVelocity = -2f; // Small constant downward force to stay grounded on slopes
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
verticalVelocity += gravity * Time.deltaTime;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Determine target speed
|
||
|
|
float targetSpeed = walkSpeed;
|
||
|
|
if (isCrouching)
|
||
|
|
{
|
||
|
|
targetSpeed = crouchSpeed;
|
||
|
|
}
|
||
|
|
else if (isSprinting && moveInput.y > 0.1f) // Sprint only when moving forwards
|
||
|
|
{
|
||
|
|
targetSpeed = sprintSpeed;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Calculate direction relative to player orientation
|
||
|
|
Vector3 targetDirection = (transform.right * moveInput.x + transform.forward * moveInput.y).normalized;
|
||
|
|
Vector3 targetHorizontalVelocity = targetDirection * (targetSpeed * Mathf.Clamp01(moveInput.magnitude));
|
||
|
|
|
||
|
|
// Smooth acceleration
|
||
|
|
currentVelocity = Vector3.MoveTowards(
|
||
|
|
currentVelocity,
|
||
|
|
targetHorizontalVelocity,
|
||
|
|
acceleration * Time.deltaTime
|
||
|
|
);
|
||
|
|
|
||
|
|
Vector3 finalMotion = currentVelocity;
|
||
|
|
finalMotion.y = verticalVelocity;
|
||
|
|
|
||
|
|
characterController.Move(finalMotion * Time.deltaTime);
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Crouch Mechanics
|
||
|
|
|
||
|
|
private void HandleCrouch()
|
||
|
|
{
|
||
|
|
float targetHeight = isCrouching ? crouchingHeight : standingHeight;
|
||
|
|
Vector3 targetCamPos = isCrouching ? crouchingCameraPosition : standingCameraPosition;
|
||
|
|
|
||
|
|
// Smoothly adjust character controller height and center
|
||
|
|
float currentHeight = Mathf.MoveTowards(characterController.height, targetHeight, crouchTransitionSpeed * Time.deltaTime);
|
||
|
|
characterController.height = currentHeight;
|
||
|
|
characterController.center = new Vector3(0, currentHeight / 2f, 0);
|
||
|
|
|
||
|
|
// Smoothly adjust camera local position
|
||
|
|
if (playerCamera != null)
|
||
|
|
{
|
||
|
|
playerCamera.localPosition = Vector3.MoveTowards(
|
||
|
|
playerCamera.localPosition,
|
||
|
|
targetCamPos,
|
||
|
|
crouchTransitionSpeed * Time.deltaTime
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private bool CanStandUp()
|
||
|
|
{
|
||
|
|
float checkDistance = standingHeight - crouchingHeight;
|
||
|
|
Vector3 rayStart = transform.position + Vector3.up * crouchingHeight;
|
||
|
|
|
||
|
|
return !Physics.SphereCast(
|
||
|
|
rayStart,
|
||
|
|
characterController.radius * 0.9f,
|
||
|
|
Vector3.up,
|
||
|
|
out _,
|
||
|
|
checkDistance,
|
||
|
|
ceilingObstructionMask,
|
||
|
|
QueryTriggerInteraction.Ignore
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Look & Rotation
|
||
|
|
|
||
|
|
private void HandleLook()
|
||
|
|
{
|
||
|
|
if (lookInput.sqrMagnitude < 0.0001f) return;
|
||
|
|
|
||
|
|
// Yaw (Horizontal player body rotation)
|
||
|
|
float mouseX = lookInput.x * mouseSensitivityX;
|
||
|
|
transform.Rotate(Vector3.up * mouseX);
|
||
|
|
|
||
|
|
// Pitch (Vertical camera rotation)
|
||
|
|
float mouseY = lookInput.y * mouseSensitivityY * (invertY ? 1f : -1f);
|
||
|
|
cameraPitch = Mathf.Clamp(cameraPitch + mouseY, lowerLookLimit, upperLookLimit);
|
||
|
|
|
||
|
|
if (playerCamera != null)
|
||
|
|
{
|
||
|
|
playerCamera.localRotation = Quaternion.Euler(cameraPitch, 0f, 0f);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Interaction (Left Click)
|
||
|
|
|
||
|
|
private void HandleInteractionDetection()
|
||
|
|
{
|
||
|
|
if (playerCamera == null) return;
|
||
|
|
|
||
|
|
Ray ray = new Ray(playerCamera.position, playerCamera.forward);
|
||
|
|
IInteractable interactable = null;
|
||
|
|
|
||
|
|
if (Physics.Raycast(ray, out RaycastHit hit, interactionDistance, interactionLayerMask, triggerInteraction))
|
||
|
|
{
|
||
|
|
interactable = hit.collider.GetComponent<IInteractable>() ?? hit.collider.GetComponentInParent<IInteractable>();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (interactable != currentHoveredInteractable)
|
||
|
|
{
|
||
|
|
currentHoveredInteractable = interactable;
|
||
|
|
string prompt = currentHoveredInteractable != null ? currentHoveredInteractable.GetPrompt() : null;
|
||
|
|
onHoverInteractableChanged?.Invoke(prompt);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnInteractPerformed(InputAction.CallbackContext context)
|
||
|
|
{
|
||
|
|
if (isMenuOpen || playerCamera == null) return;
|
||
|
|
|
||
|
|
Ray ray = new Ray(playerCamera.position, playerCamera.forward);
|
||
|
|
if (Physics.Raycast(ray, out RaycastHit hit, interactionDistance, interactionLayerMask, triggerInteraction))
|
||
|
|
{
|
||
|
|
IInteractable interactable = hit.collider.GetComponent<IInteractable>() ?? hit.collider.GetComponentInParent<IInteractable>();
|
||
|
|
if (interactable != null)
|
||
|
|
{
|
||
|
|
interactable.Interact(gameObject);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Menu Toggle (Right Click)
|
||
|
|
|
||
|
|
private void OnMenuPerformed(InputAction.CallbackContext context)
|
||
|
|
{
|
||
|
|
ToggleMenu();
|
||
|
|
}
|
||
|
|
|
||
|
|
public void ToggleMenu()
|
||
|
|
{
|
||
|
|
SetMenuState(!isMenuOpen);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void SetMenuState(bool open)
|
||
|
|
{
|
||
|
|
isMenuOpen = open;
|
||
|
|
|
||
|
|
if (menuUI != null)
|
||
|
|
{
|
||
|
|
menuUI.SetActive(isMenuOpen);
|
||
|
|
}
|
||
|
|
|
||
|
|
SetCursorState(!isMenuOpen);
|
||
|
|
onMenuToggled?.Invoke(isMenuOpen);
|
||
|
|
|
||
|
|
// Reset velocity when opening menu
|
||
|
|
if (isMenuOpen)
|
||
|
|
{
|
||
|
|
currentVelocity = Vector3.zero;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
|
||
|
|
#region Utilities & Cursor
|
||
|
|
|
||
|
|
public void SetCursorState(bool locked)
|
||
|
|
{
|
||
|
|
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
|
||
|
|
Cursor.visible = !locked;
|
||
|
|
}
|
||
|
|
|
||
|
|
private void OnDrawGizmosSelected()
|
||
|
|
{
|
||
|
|
if (playerCamera != null)
|
||
|
|
{
|
||
|
|
Gizmos.color = Color.cyan;
|
||
|
|
Gizmos.DrawRay(playerCamera.position, playerCamera.forward * interactionDistance);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#endregion
|
||
|
|
}
|