using UnityEngine; using Mirror; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; public class CharacterMovement : NetworkBehaviour { Animator animator; int isWalkingHash; int isRunningHash; PlayerInput input; Vector2 currentMovement; Vector2 combinedMovement; bool movementPressed; bool runPressed; // track last sent state to avoid network spam bool lastSentWalking; bool lastSentRunning; Camera mainCamera; // Mouse-look orbit camera: yaw/pitch driven purely by the mouse, never by the player's // own transform.rotation, so turning/facing movement direction doesn't snap the camera. [Header("Camera Look")] public float cameraDistance = 4.5f; public float cameraPivotHeight = 1.6f; public float cameraPitchDefault = 15f; public float cameraPitchMin = -10f; public float cameraPitchMax = 65f; public float mouseSensitivity = 6f; // Degrees/sec at full stick deflection — a different scale than mouseSensitivity since // the right stick reports a steady -1..1 tilt rather than a per-frame pixel delta. public float gamepadLookSensitivity = 150f; // Sideways offset of the orbit pivot (over-the-shoulder), so the character doesn't // block the center of the screen. Weapons set this; 0 keeps the classic centered orbit. [HideInInspector] public float cameraShoulderOffset = 0f; // While true the character turns to face the camera's yaw (strafe-style, like a shooter) // instead of facing its movement direction. [HideInInspector] public bool faceCameraYaw; [Header("Camera Recoil")] public float recoilKickSharpness = 35f; public float recoilRecoverDelay = 0.12f; public float recoilRecoverSharpness = 7f; float cameraYaw; float cameraPitch; bool wantsCursorLocked = true; Vector2 currentLook; // Recoil lives in its own offset on top of the mouse-driven yaw/pitch, so it can kick // and then recover back to where the player was aiming without fighting mouse input. Vector2 recoilTarget; // x = degrees up, y = degrees right Vector2 recoilCurrent; float lastRecoilTime = -10f; public float CameraYaw => cameraYaw; // Set by cutscenes (e.g. RangeIntroCinematic): freezes movement/look and hands the camera over. public static bool CinematicControl; public void AddRecoil(float up, float right) { recoilTarget += new Vector2(up, right); lastRecoilTime = Time.time; } void Awake() { input = new PlayerInput(); input.CharacterControls.Movement.performed += ctx => { currentMovement = ctx.ReadValue(); movementPressed = currentMovement.x != 0 || currentMovement.y != 0; }; input.CharacterControls.Movement.canceled += ctx => { currentMovement = Vector2.zero; movementPressed = false; }; input.CharacterControls.Run.performed += ctx => runPressed = ctx.ReadValueAsButton(); input.CharacterControls.Run.canceled += ctx => runPressed = false; // Right stick — camera look, same yaw/pitch the mouse drives (see LateUpdate). input.CharacterControls.Look.performed += ctx => currentLook = ctx.ReadValue(); input.CharacterControls.Look.canceled += ctx => currentLook = Vector2.zero; } void Start() { animator = GetComponent(); isWalkingHash = Animator.StringToHash("isWalking"); isRunningHash = Animator.StringToHash("isRunning"); } // Only the local player should drive input/movement void Update() { if (!isLocalPlayer) return; if (CinematicControl) { ForceIdle(); return; } HandleMovement(); HandleRotation(); } // Camera follow/look should be in LateUpdate so it reacts to the final character pose // for the frame instead of lagging a frame behind Update's movement. void LateUpdate() { if (!isLocalPlayer) return; if (mainCamera == null) return; // A cinematic owns the camera: no look input, no camera writes, cursor stays hidden. if (CinematicControl) { ApplyCursorLockState(true); return; } var kb = Keyboard.current; if (kb != null && kb.escapeKey.wasPressedThisFrame) { if (ChatUI.IsInputFieldFocused) { ChatUI.Instance?.ExitChatFocus(); wantsCursorLocked = true; } else { wantsCursorLocked = !wantsCursorLocked; } } // Re-assert the lock state every frame rather than trusting Cursor.lockState to have // stuck: the Editor Game view (and some platforms) silently drop the lock on focus // loss, so gating mouse-look on reading it back can permanently stall the camera. bool shouldLockNow = wantsCursorLocked && !ChatUI.IsInputFieldFocused; ApplyCursorLockState(shouldLockNow); bool canLook = !ChatUI.IsInputFieldFocused && Application.isFocused; if (canLook && shouldLockNow) { var mouse = Mouse.current; if (mouse != null) { Vector2 delta = mouse.delta.ReadValue(); cameraYaw += delta.x * mouseSensitivity * Time.deltaTime; cameraPitch = Mathf.Clamp(cameraPitch - delta.y * mouseSensitivity * Time.deltaTime, cameraPitchMin, cameraPitchMax); // CursorLockMode.Locked hides the cursor but doesn't reliably keep the OS // pointer confined/re-centered (especially inside the Editor Game view), so // without this the real cursor eventually reaches a screen edge and rotation // caps out in that direction instead of spinning freely. Warping back to // center every frame removes that ceiling — but WarpCursorPosition alone only // moves the OS cursor; the Input System's own tracked position doesn't know // about the jump and keeps computing future deltas as if the cursor were still // out near the edge, which is what was still capping rotation. InputState.Change // tells the Input System directly where the cursor now is, so the next delta // read is correct instead of walled off. Vector2 center = new Vector2(Screen.width * 0.5f, Screen.height * 0.5f); mouse.WarpCursorPosition(center); InputState.Change(mouse.position, center); } } // Right stick look — independent of cursor lock (a gamepad doesn't have a cursor to // fight over), but still suppressed while typing in chat or the window is unfocused. if (canLook && currentLook.sqrMagnitude > 0.0001f) { cameraYaw += currentLook.x * gamepadLookSensitivity * Time.deltaTime; cameraPitch = Mathf.Clamp(cameraPitch - currentLook.y * gamepadLookSensitivity * Time.deltaTime, cameraPitchMin, cameraPitchMax); } UpdateRecoil(); UpdateCameraTransform(); } void UpdateRecoil() { if (Time.time - lastRecoilTime > recoilRecoverDelay) recoilTarget = Vector2.Lerp(recoilTarget, Vector2.zero, 1f - Mathf.Exp(-recoilRecoverSharpness * Time.deltaTime)); recoilCurrent = Vector2.Lerp(recoilCurrent, recoilTarget, 1f - Mathf.Exp(-recoilKickSharpness * Time.deltaTime)); } void ApplyCursorLockState(bool locked) { Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None; Cursor.visible = !locked; } // Orbits the camera around a pivot above the player at a fixed distance, using // cameraYaw/cameraPitch — never the player's own transform.rotation. void UpdateCameraTransform() { GetGameplayCameraPose(out Vector3 position, out Quaternion rotation); mainCamera.transform.SetPositionAndRotation(position, rotation); } // Where the gameplay camera sits this frame — cinematics blend into this pose so the // hand-off back to the player is seamless. public void GetGameplayCameraPose(out Vector3 position, out Quaternion rotation) { rotation = Quaternion.Euler(cameraPitch - recoilCurrent.x, cameraYaw + recoilCurrent.y, 0f); Vector3 pivot = transform.position + Vector3.up * cameraPivotHeight; position = pivot + rotation * new Vector3(cameraShoulderOffset, 0f, -cameraDistance); } // WASD input is camera-relative (like most third-person games): "forward" is whichever // way the camera currently faces, projected onto the ground, not a fixed world axis or // the player's own facing. Turning the camera therefore turns which way W walks you. Vector3 GetCameraRelativeMoveDirection() { Quaternion cameraYawRotation = Quaternion.Euler(0f, cameraYaw, 0f); return cameraYawRotation * new Vector3(combinedMovement.x, 0f, combinedMovement.y); } // While a cinematic holds control, HandleMovement doesn't run to clear the walk/run flags — // and with root motion on, a stuck walk state would stroll the character across the map. void ForceIdle() { if (animator == null) return; animator.SetBool(isWalkingHash, false); animator.SetBool(isRunningHash, false); if (lastSentWalking || lastSentRunning) { lastSentWalking = false; lastSentRunning = false; CmdSendAnimationState(false, false); } } void HandleRotation() { if (faceCameraYaw) { Quaternion aimYaw = Quaternion.Euler(0f, cameraYaw, 0f); transform.rotation = Quaternion.Slerp(transform.rotation, aimYaw, 1f - Mathf.Exp(-20f * Time.deltaTime)); return; } Vector3 currentPosition = transform.position; Vector3 moveDirection = GetCameraRelativeMoveDirection(); // only rotate when there is movement to look at if (moveDirection.sqrMagnitude > 0f) { Vector3 positionToLookAt = currentPosition + moveDirection; transform.LookAt(positionToLookAt); } } void HandleMovement() { // read keyboard WASD (supplements / works alongside Input System action bindings) Vector2 keyboardMovement = Vector2.zero; var kb = Keyboard.current; if (kb != null) { if (kb.wKey.isPressed) keyboardMovement.y += 1f; if (kb.sKey.isPressed) keyboardMovement.y -= 1f; if (kb.aKey.isPressed) keyboardMovement.x -= 1f; if (kb.dKey.isPressed) keyboardMovement.x += 1f; } // Disable keyboard WASD while typing in chat input if (ChatUI.IsInputFieldFocused) { keyboardMovement = Vector2.zero; } // combine input action movement and keyboard movement combinedMovement = currentMovement + keyboardMovement; // prevent faster diagonal movement combinedMovement = Vector2.ClampMagnitude(combinedMovement, 1f); movementPressed = combinedMovement.x != 0 || combinedMovement.y != 0; // detect Shift keys as an additional run input bool shiftPressed = kb != null && (kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed); bool runActive = runPressed || shiftPressed; bool isRunning = animator.GetBool(isRunningHash); bool isWalking = animator.GetBool(isWalkingHash); if (movementPressed && !isWalking) { animator.SetBool(isWalkingHash, true); isWalking = true; } if (!movementPressed && isWalking) { animator.SetBool(isWalkingHash, false); isWalking = false; } if ((movementPressed && runActive) && !isRunning) { animator.SetBool(isRunningHash, true); isRunning = true; } if ((!movementPressed || !runActive) && isRunning) { animator.SetBool(isRunningHash, false); isRunning = false; } // send animator state to server only when changed if (isWalking != lastSentWalking || isRunning != lastSentRunning) { lastSentWalking = isWalking; lastSentRunning = isRunning; CmdSendAnimationState(isWalking, isRunning); } // movement translation, camera-relative (see GetCameraRelativeMoveDirection) float speed = isRunning ? 6f : 3f; Vector3 move = GetCameraRelativeMoveDirection() * speed * Time.deltaTime; transform.position += move; } public override void OnStartLocalPlayer() { Debug.Log("Local player started: " + netId); input.CharacterControls.Enable(); // Setup camera for local player (unparented — position/rotation are driven every // frame from LateUpdate/UpdateCameraTransform, not by being a child of the player). mainCamera = Camera.main; if (mainCamera == null) { Debug.LogWarning("No Camera.main found in scene to attach to player."); return; } // Start aligned behind the player's current facing; mouse look takes over from here. cameraYaw = transform.eulerAngles.y; cameraPitch = cameraPitchDefault; mainCamera.transform.SetParent(null, true); UpdateCameraTransform(); wantsCursorLocked = true; ApplyCursorLockState(wantsCursorLocked); // ensure only the local player's camera has an active AudioListener var audio = mainCamera.GetComponent(); if (audio != null) audio.enabled = true; } public override void OnStopLocalPlayer() { input.CharacterControls.Disable(); // optionally disable audio listener to avoid duplicates if (mainCamera != null) { var audio = mainCamera.GetComponent(); if (audio != null) audio.enabled = false; } mainCamera = null; wantsCursorLocked = false; ApplyCursorLockState(false); } // Called on the server when client issues the command [Command] void CmdSendAnimationState(bool walking, bool running) { // update server-side animator if present if (animator == null) animator = GetComponent(); if (animator != null) { animator.SetBool(isWalkingHash, walking); animator.SetBool(isRunningHash, running); } // broadcast to all clients RpcUpdateAnimationState(walking, running); } // Executed on all clients to apply animator changes [ClientRpc] void RpcUpdateAnimationState(bool walking, bool running) { if (animator == null) animator = GetComponent(); if (animator == null) return; animator.SetBool(isWalkingHash, walking); animator.SetBool(isRunningHash, running); } }