using UnityEngine; using Mirror; using UnityEngine.InputSystem; 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; // assign this in the player prefab to an empty child transform positioned where the camera should sit (e.g. head) public Transform cameraMount; // camera follow settings Camera mainCamera; Vector3 cameraLocalOffsetStable = new Vector3(0f, 2f, -4f); // fallback offset (local) public float followSpeed = 5f; public float idleFollowSpeed = 0.5f; // slower smoothing while idle (keeps camera stable) public Vector3 cameraLookOffset = new Vector3(0f, 1.5f, 0f); // where camera looks relative to player root 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; } 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; HandleMovement(); HandleRotation(); } // Camera follow should be in LateUpdate so it follows the final character pose for the frame void LateUpdate() { if (!isLocalPlayer) return; if (mainCamera == null) return; // Desired world position based on the stable local offset (relative to player root) Vector3 desiredWorld = transform.TransformPoint(cameraLocalOffsetStable); if (movementPressed) { // while moving: follow the player (smooth) mainCamera.transform.position = Vector3.Lerp(mainCamera.transform.position, desiredWorld, Time.deltaTime * followSpeed); } else { // while idle: keep camera stable behind the player (don't follow micro animation bobbing) // Update only XZ to remain behind, preserve camera's current Y (height) to avoid vertical bob Vector3 idleTarget = new Vector3(desiredWorld.x, mainCamera.transform.position.y, desiredWorld.z); mainCamera.transform.position = Vector3.Lerp(mainCamera.transform.position, idleTarget, Time.deltaTime * idleFollowSpeed); } // Always look at the player (with an offset so we look near the torso/head) mainCamera.transform.LookAt(transform.position + cameraLookOffset); } void HandleRotation() { Vector3 currentPosition = transform.position; Vector3 newPosition = new Vector3(combinedMovement.x, 0, combinedMovement.y); // only rotate when there is movement to look at if (newPosition.sqrMagnitude > 0f) { Vector3 positionToLookAt = currentPosition + newPosition; 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 (local) float speed = isRunning ? 6f : 3f; Vector3 move = new Vector3(combinedMovement.x, 0, combinedMovement.y) * speed * Time.deltaTime; transform.position += move; } public override void OnStartLocalPlayer() { Debug.Log("Local player started: " + netId); input.CharacterControls.Enable(); // Setup camera for local player (do not parent to animated mount to avoid animation bobbing) mainCamera = Camera.main; if (mainCamera == null) { Debug.LogWarning("No Camera.main found in scene to attach to player."); return; } // compute a stable local offset based on cameraMount, but use player root as the reference so animated child motion is ignored if (cameraMount != null) { cameraLocalOffsetStable = transform.InverseTransformPoint(cameraMount.position); } else { // fallback to a sensible offset if no mount assigned cameraLocalOffsetStable = new Vector3(0f, 2f, -4f); Debug.LogWarning("cameraMount not assigned on player prefab. Using fallback offset."); } // unparent camera so we control world position directly mainCamera.transform.SetParent(null, true); // 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; } // 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); } }