using UnityEngine; using Unity.Cinemachine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif /// /// Free-flying spectator camera. Drives its OWN dedicated by moving /// this transform with WASD (relative to where it looks), Q/E (or Space/Left Ctrl) for down/up /// and the mouse for look. Collision is a capsule sized like the /// player's, so the freecam slides along world geometry instead of clipping through walls/floors. /// /// Activation is driven by via : it raises the vcam /// priority so the CinemachineBrain blends to it, snaps the rig to the current view (no jump) and /// locks the cursor. While inactive the vcam rests at a low priority and this component does nothing. /// [RequireComponent(typeof(CharacterController))] [DisallowMultipleComponent] public class FreeCamController : MonoBehaviour { [Header("Flight")] [Tooltip("Base flight speed (units/second).")] public float moveSpeed = 6f; [Tooltip("Speed multiplier while the sprint key (Left Shift) is held.")] public float sprintMultiplier = 3f; [Header("Look")] [Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")] public float horizontalSensitivity = 0.2f; [Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")] public float verticalSensitivity = 0.15f; [Tooltip("Invert vertical look (mouse up tilts the camera down).")] public bool invertY = false; [Tooltip("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")] [Range(1f, 89.9f)] public float maxPitch = 89f; [Header("Smoothing")] [Tooltip("Acceleration/deceleration smoothing for flight, in seconds. 0 = instant (snappy starts/stops); higher = floatier ramp-up and coast-down.")] [Range(0f, 1f)] public float moveSmoothTime = 0.12f; [Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")] [Range(0f, 0.3f)] public float lookSmoothTime = 0.04f; [Header("Camera")] [Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")] public int activePriority = 100; [Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")] public int inactivePriority = -100; CharacterController _controller; CinemachineCamera _vcam; float _yaw; // look target (raw input accumulates here) float _pitch; float _displayYaw; // smoothed value actually applied to the transform float _displayPitch; float _yawSmoothRef; float _pitchSmoothRef; Vector3 _moveVelocity; // smoothed world-space flight velocity Vector3 _moveVelocityRef; bool _ignoredPlayer; void Awake() { _controller = GetComponent(); _vcam = GetComponent(); if (!_vcam) Debug.LogError("[FreeCamController] No CinemachineCamera on this GameObject; freecam cannot go live.", this); else _vcam.Priority = inactivePriority; // Dormant until FreeCamMode enters; nothing reads input or moves the rig meanwhile. enabled = false; } /// Enter/leave freecam. Raises/lowers the vcam priority, snaps to the live view and /// manages the cursor. Called by . public void SetActive(bool active) { if (_vcam) _vcam.Priority = active ? activePriority : inactivePriority; if (active) { IgnorePlayerCollision(); SyncToCurrentView(); SetCursorLocked(true); } else { SetCursorLocked(false); } enabled = active; } void Update() { LookTick(); MoveTick(); } void LookTick() { Vector2 delta = ReadMouseDelta() * MouseSensitivity.Multiplier; _yaw += delta.x * horizontalSensitivity; float vs = verticalSensitivity * (invertY ? 1f : -1f); _pitch = Mathf.Clamp(_pitch + delta.y * vs, -maxPitch, maxPitch); if (lookSmoothTime > 0f) { // SmoothDampAngle is frame-rate-independent and wraps yaw correctly. _displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, lookSmoothTime); _displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, lookSmoothTime); } else { _displayYaw = _yaw; _displayPitch = _pitch; } // Rotation is unconstrained by the CharacterController; only position is. transform.rotation = Quaternion.Euler(_displayPitch, _displayYaw, 0f); } void MoveTick() { Vector3 dir = ReadMoveDirection(); if (dir.sqrMagnitude > 1f) dir.Normalize(); float speed = moveSpeed * (SprintHeld() ? sprintMultiplier : 1f); Vector3 targetVelocity = dir * speed; // SmoothDamp eases accel/decel and is already dt-correct; 0 collapses to instant. _moveVelocity = moveSmoothTime > 0f ? Vector3.SmoothDamp(_moveVelocity, targetVelocity, ref _moveVelocityRef, moveSmoothTime) : targetVelocity; _controller.Move(_moveVelocity * Time.deltaTime); } /// Place the rig at the currently rendered view so going live produces no visible jump. void SyncToCurrentView() { var cam = Camera.main; if (!cam) return; // CharacterController forbids direct transform writes while enabled, so toggle it for the teleport. bool wasEnabled = _controller.enabled; _controller.enabled = false; transform.SetPositionAndRotation(cam.transform.position, cam.transform.rotation); _controller.enabled = wasEnabled; Vector3 e = transform.rotation.eulerAngles; _yaw = e.y; float p = e.x > 180f ? e.x - 360f : e.x; _pitch = Mathf.Clamp(p, -maxPitch, maxPitch); // Snap the smoothed state to the fresh pose so entering freecam never lags/jumps to catch up. _displayYaw = _yaw; _displayPitch = _pitch; _yawSmoothRef = 0f; _pitchSmoothRef = 0f; _moveVelocity = Vector3.zero; _moveVelocityRef = Vector3.zero; } /// Stop the freecam capsule from being blocked by the (frozen) player's capsule. void IgnorePlayerCollision() { if (_ignoredPlayer) return; _ignoredPlayer = true; var player = FindFirstObjectByType(); if (player) { var playerCC = player.GetComponent(); // Only an enabled capsule can take part in a collision-ignore (a spectator's body has its CC // disabled, and there is nothing to collide with there anyway). if (playerCC && playerCC.enabled) Physics.IgnoreCollision(_controller, playerCC, true); } } Vector3 ReadMoveDirection() { Vector3 dir = Vector3.zero; #if ENABLE_INPUT_SYSTEM var kb = Keyboard.current; if (kb != null) { if (kb.wKey.isPressed) dir += transform.forward; if (kb.sKey.isPressed) dir -= transform.forward; if (kb.dKey.isPressed) dir += transform.right; if (kb.aKey.isPressed) dir -= transform.right; if (kb.eKey.isPressed || kb.spaceKey.isPressed) dir += Vector3.up; if (kb.qKey.isPressed || kb.leftCtrlKey.isPressed) dir -= Vector3.up; } #else dir += transform.forward * Input.GetAxisRaw("Vertical"); dir += transform.right * Input.GetAxisRaw("Horizontal"); if (Input.GetKey(KeyCode.E) || Input.GetKey(KeyCode.Space)) dir += Vector3.up; if (Input.GetKey(KeyCode.Q) || Input.GetKey(KeyCode.LeftControl)) dir -= Vector3.up; #endif return dir; } bool SprintHeld() { #if ENABLE_INPUT_SYSTEM return Keyboard.current != null && Keyboard.current.leftShiftKey.isPressed; #else return Input.GetKey(KeyCode.LeftShift); #endif } Vector2 ReadMouseDelta() { #if ENABLE_INPUT_SYSTEM return Mouse.current != null ? Mouse.current.delta.ReadValue() : Vector2.zero; #else return new Vector2(Input.GetAxisRaw("Mouse X"), Input.GetAxisRaw("Mouse Y")) * 10f; #endif } static void SetCursorLocked(bool locked) { Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None; Cursor.visible = !locked; } }