Add practice range: shooting, AK47, intro cinematic, dressed warehouse scene

- 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>
This commit is contained in:
pelpanagiotis
2026-09-25 08:56:35 +03:00
co-authored by Claude Opus 5.5
parent c674d76549
commit 5fc9ebf1e6
211 changed files with 34492 additions and 177 deletions
+202 -45
View File
@@ -1,6 +1,7 @@
using UnityEngine;
using Mirror;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
public class CharacterMovement : NetworkBehaviour
{
@@ -20,15 +21,55 @@ public class CharacterMovement : NetworkBehaviour
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
// 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()
{
@@ -47,6 +88,10 @@ public class CharacterMovement : NetworkBehaviour
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()
@@ -61,45 +106,159 @@ public class CharacterMovement : NetworkBehaviour
void Update()
{
if (!isLocalPlayer) return;
if (CinematicControl)
{
ForceIdle();
return;
}
HandleMovement();
HandleRotation();
}
// Camera follow should be in LateUpdate so it follows the final character pose for the frame
// 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;
// Desired world position based on the stable local offset (relative to player root)
Vector3 desiredWorld = transform.TransformPoint(cameraLocalOffsetStable);
if (movementPressed)
// A cinematic owns the camera: no look input, no camera writes, cursor stays hidden.
if (CinematicControl)
{
// 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);
ApplyCursorLockState(true);
return;
}
// Always look at the player (with an offset so we look near the torso/head)
mainCamera.transform.LookAt(transform.position + cameraLookOffset);
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()
{
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)
if (faceCameraYaw)
{
Vector3 positionToLookAt = currentPosition + newPosition;
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);
}
}
@@ -168,9 +327,9 @@ public class CharacterMovement : NetworkBehaviour
CmdSendAnimationState(isWalking, isRunning);
}
// movement translation (local)
// movement translation, camera-relative (see GetCameraRelativeMoveDirection)
float speed = isRunning ? 6f : 3f;
Vector3 move = new Vector3(combinedMovement.x, 0, combinedMovement.y) * speed * Time.deltaTime;
Vector3 move = GetCameraRelativeMoveDirection() * speed * Time.deltaTime;
transform.position += move;
}
@@ -179,7 +338,8 @@ public class CharacterMovement : NetworkBehaviour
Debug.Log("Local player started: " + netId);
input.CharacterControls.Enable();
// Setup camera for local player (do not parent to animated mount to avoid animation bobbing)
// 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)
{
@@ -187,20 +347,14 @@ public class CharacterMovement : NetworkBehaviour
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
// 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>();
@@ -218,6 +372,9 @@ public class CharacterMovement : NetworkBehaviour
if (audio != null) audio.enabled = false;
}
mainCamera = null;
wantsCursorLocked = false;
ApplyCursorLockState(false);
}
// Called on the server when client issues the command