- PortalTestDestination practice range: Valorant-style hitscan rifle (recoil, spray/bloom, reload, controller support), debug HUD, practice-dummy targets with head/body hitboxes, hit/death/respawn animations, procedural SFX/FX - AK47 model (Quaternius, CC0) held via Animator IK, with fire/reload/equip anims - Intro cinematic (camera shots, title card, target beam-in, GO hand-off) - Scene dressing editor pass: CC0 textures, props, warehouse roof, lighting and post-processing; custom stylized skybox shader - CharacterMovement: camera recoil, shoulder offset, cinematic control lock - Unity CLI pipeline package; plus in-progress chat/Rive/player changes Asset credits: Assets/Models/Props/PROPS_CREDITS.txt (3 CC-BY 3.0 props), Assets/Textures/Range/TEXTURES_LICENSE.txt, Assets/Resources/*/..._LICENSE.txt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
15 KiB
C#
407 lines
15 KiB
C#
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<Vector2>();
|
|
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<Vector2>();
|
|
input.CharacterControls.Look.canceled += ctx => currentLook = Vector2.zero;
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
animator = GetComponent<Animator>();
|
|
|
|
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<AudioListener>();
|
|
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<AudioListener>();
|
|
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<Animator>();
|
|
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<Animator>();
|
|
if (animator == null) return;
|
|
|
|
animator.SetBool(isWalkingHash, walking);
|
|
animator.SetBool(isRunningHash, running);
|
|
}
|
|
}
|