Files
HorrorStories/Assets/Scripts/PlayerController.cs

491 lines
16 KiB
C#
Raw Normal View History

2026-08-25 16:24:29 +01:00
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
}