using System.Collections.Generic; using Mirror; using UnityEngine; using UnityEngine.InputSystem; // Valorant-style hitscan rifle (Vandal-like numbers), local-only prototype: // - Full auto while holding LMB / Right Trigger; R / X (Square) to reload; auto-reload on empty. // - First-shot accuracy: standing still and not spraying = perfectly accurate. // - Spraying kicks the camera (climbs up, then wanders sideways) and adds bloom (random cone); // the camera recovers back to your aim once you stop firing. // - Moving adds a large inaccuracy penalty (counter-strafe to stop and shoot). // Shots are raycast from the center of the screen (crosshair), skipping the local player's own colliders. // On the local player it also sets up the WeaponRig (held rifle + animations) and an over-the-shoulder camera. public class HitscanShooter : MonoBehaviour { [Header("Weapon")] public string weaponName = "Vandal (debug)"; public float roundsPerSecond = 9.75f; public int magazineSize = 25; public float reloadTime = 2.5f; public float range = 150f; public float bodyDamage = 40f; public float headDamage = 160f; [Header("Accuracy (degrees)")] public float bloomPerShot = 0.25f; public float maxBloom = 2f; public float movingInaccuracy = 4f; public float movingSpeedThreshold = 0.5f; // Time without firing before spray resets back to first-shot accuracy. public float sprayResetTime = 0.35f; [Header("Camera recoil (degrees per shot)")] public float recoilUpEarly = 0.9f; // first shots climb hard public float recoilUpLate = 0.2f; // then the climb flattens out... public int recoilClimbShots = 9; public float recoilSideways = 0.55f; // ...and starts swaying left/right [Header("Third-person aim camera")] public float shoulderOffset = 0.6f; public float aimCameraDistance = 3f; [Header("Controller")] public float triggerThreshold = 0.5f; public float rumbleLow = 0.15f; public float rumbleHigh = 0.35f; public float rumbleDuration = 0.06f; int ammo; bool reloading; float reloadDoneAt; float nextShotAt; float lastShotTime = -10f; int sprayIndex; int shotsFired; int hits; int headshots; int kills; bool isMoving; float lastSpread; Vector3 lastPlayerPosition; bool hasLastPlayerPosition; bool usingGamepad; float rumbleStopAt = -1f; float hitmarkerUntil; bool hitmarkerHead; bool hitmarkerKill; readonly List log = new List(); NetworkIdentity equippedPlayer; CharacterMovement movement; WeaponRig rig; float originalCameraDistance; AudioSource audioSource; GUIStyle hudStyle; GUIStyle hitmarkerStyle; void Start() { ammo = magazineSize; audioSource = gameObject.AddComponent(); audioSource.playOnAwake = false; audioSource.spatialBlend = 0f; } void OnDestroy() { Unequip(); StopRumble(); } void Update() { EnsureEquipped(); UpdateMovementState(); UpdateAimPoint(); // The intro cinematic owns the camera and input; no shooting until it hands control back. if (RangeIntroCinematic.IsPlaying) return; if (rumbleStopAt > 0f && Time.time >= rumbleStopAt) StopRumble(); if (reloading && Time.time >= reloadDoneAt) { reloading = false; ammo = magazineSize; AddLog("Reload complete"); } if (Time.time - lastShotTime > sprayResetTime) sprayIndex = 0; var mouse = Mouse.current; var kb = Keyboard.current; var pad = Gamepad.current; bool mouseAllowed = CanUseMouse(); bool padAllowed = CanUseGamepad(); bool mouseHeld = mouseAllowed && mouse != null && mouse.leftButton.isPressed; bool mousePressed = mouseAllowed && mouse != null && mouse.leftButton.wasPressedThisFrame; bool padHeld = padAllowed && pad != null && pad.rightTrigger.ReadValue() > triggerThreshold; bool padPressed = padAllowed && pad != null && pad.rightTrigger.wasPressedThisFrame; if (padHeld || (padAllowed && pad != null && pad.buttonWest.wasPressedThisFrame)) usingGamepad = true; if (mouseHeld || (mouseAllowed && kb != null && kb.rKey.wasPressedThisFrame)) usingGamepad = false; bool reloadPressed = (mouseAllowed && kb != null && kb.rKey.wasPressedThisFrame) || (padAllowed && pad != null && pad.buttonWest.wasPressedThisFrame); if (reloadPressed) StartReload(); if (!(mouseHeld || padHeld)) return; if (reloading || Time.time < nextShotAt) return; if (ammo <= 0) { if (mousePressed || padPressed) { audioSource.PlayOneShot(ShootingFx.EmptyClick); StartReload(); } return; } Fire(); } // Mouse only counts while the game "has" the cursor (locked; Esc toggles it in // CharacterMovement). A controller has no cursor to fight over, so it only needs // the chat box to be unfocused. bool CanUseMouse() => CanUseGamepad() && Cursor.lockState == CursorLockMode.Locked; bool CanUseGamepad() => Camera.main != null && !ChatUI.IsInputFieldFocused && Application.isFocused; // Arms the local player once it exists (it may spawn after this scene object). void EnsureEquipped() { var player = NetworkClient.localPlayer; if (player == equippedPlayer) return; Unequip(); if (player == null) return; equippedPlayer = player; movement = player.GetComponent(); if (movement != null) { originalCameraDistance = movement.cameraDistance; movement.cameraDistance = aimCameraDistance; movement.cameraShoulderOffset = shoulderOffset; movement.faceCameraYaw = true; } var animator = player.GetComponentInChildren(); if (animator != null && animator.isHuman) rig = animator.gameObject.AddComponent(); } void Unequip() { if (movement != null) { movement.cameraDistance = originalCameraDistance; movement.cameraShoulderOffset = 0f; movement.faceCameraYaw = false; } if (rig != null) Destroy(rig); movement = null; rig = null; equippedPlayer = null; } void UpdateMovementState() { var player = NetworkClient.localPlayer; if (player == null || Time.deltaTime <= 0f) { isMoving = false; hasLastPlayerPosition = false; return; } Vector3 pos = player.transform.position; if (hasLastPlayerPosition) { Vector3 delta = pos - lastPlayerPosition; delta.y = 0f; isMoving = delta.magnitude / Time.deltaTime > movingSpeedThreshold; } lastPlayerPosition = pos; hasLastPlayerPosition = true; } void UpdateAimPoint() { if (rig == null || Camera.main == null) return; // During the cinematic the camera is off doing its own thing; aim where the body faces. if (RangeIntroCinematic.IsPlaying && equippedPlayer != null) { var body = equippedPlayer.transform; rig.SetAimPoint(body.position + Vector3.up * 1.4f + body.forward * 20f); return; } var cam = Camera.main.transform; var ray = new Ray(cam.position, cam.forward); rig.SetAimPoint(TryRaycast(ray, out RaycastHit hit) ? hit.point : ray.GetPoint(range)); } void StartReload() { if (reloading || ammo == magazineSize) return; reloading = true; reloadDoneAt = Time.time + reloadTime; if (rig != null) rig.PlayReload(reloadTime); audioSource.PlayOneShot(ShootingFx.MagOut); Invoke(nameof(PlayMagInSound), reloadTime * 0.75f); AddLog("Reloading..."); } void PlayMagInSound() => audioSource.PlayOneShot(ShootingFx.MagIn); void Fire() { ammo--; shotsFired++; nextShotAt = Time.time + 1f / roundsPerSecond; lastShotTime = Time.time; var cam = Camera.main.transform; // Bloom: random cone that grows while spraying; zero on the first shot. float bloom = Mathf.Min(sprayIndex * bloomPerShot, maxBloom); float spread = bloom + (isMoving ? movingInaccuracy : 0f); lastSpread = spread; Vector2 randomOffset = Random.insideUnitCircle * spread; Quaternion offset = Quaternion.AngleAxis(-randomOffset.y, cam.right) * Quaternion.AngleAxis(randomOffset.x, cam.up); Vector3 direction = offset * cam.forward; ApplyRecoil(); sprayIndex++; audioSource.PlayOneShot(ShootingFx.Gunshot, 0.8f); if (rig != null) rig.PlayFire(); if (usingGamepad) StartRumble(); Vector3 tracerStart = rig != null ? rig.Muzzle.position : cam.position; if (!TryRaycast(new Ray(cam.position, direction), out RaycastHit hit)) { ShootingFx.SpawnTracer(tracerStart, cam.position + direction * range); AddLog($"#{shotsFired} MISS (spread {spread:0.0}°)"); return; } ShootingFx.SpawnTracer(tracerStart, hit.point); var hitbox = hit.collider.GetComponent(); ShootingFx.SpawnImpact(hit.point, hit.normal, fleshHit: hitbox != null); if (hitbox == null || hitbox.Owner == null) { AddLog($"#{shotsFired} hit wall '{hit.collider.name}' ({hit.distance:0.0}m)"); return; } float damage = hitbox.IsHead ? headDamage : bodyDamage; bool killed = hitbox.Owner.TakeDamage(damage, direction, hitbox.IsHead); hits++; if (hitbox.IsHead) headshots++; if (killed) kills++; hitmarkerUntil = Time.time + 0.15f; hitmarkerHead = hitbox.IsHead; hitmarkerKill = killed; audioSource.PlayOneShot(killed ? ShootingFx.KillConfirm : hitbox.IsHead ? ShootingFx.HitHead : ShootingFx.HitBody); string zone = hitbox.IsHead ? "HEAD" : "BODY"; string result = killed ? " -> KILL" : $" ({Mathf.CeilToInt(hitbox.Owner.Health)} HP left)"; AddLog($"#{shotsFired} {zone} {damage:0} dmg on {hitbox.Owner.name} ({hit.distance:0.0}m){result}"); } // Vandal-like pattern: strong vertical climb for the first shots, then a flatter climb // with a left/right sway. Applied to the camera, so the crosshair itself moves. void ApplyRecoil() { if (movement == null) return; float up = sprayIndex < recoilClimbShots ? recoilUpEarly : recoilUpLate; float right = sprayIndex >= recoilClimbShots ? Mathf.Sin((sprayIndex - recoilClimbShots) * 0.7f) * recoilSideways : Random.Range(-0.1f, 0.1f); movement.AddRecoil(up, right); } void StartRumble() { var pad = Gamepad.current; if (pad == null) return; pad.SetMotorSpeeds(rumbleLow, rumbleHigh); rumbleStopAt = Time.time + rumbleDuration; } void StopRumble() { rumbleStopAt = -1f; Gamepad.current?.SetMotorSpeeds(0f, 0f); } // RaycastAll so the ray passes through the local player's own body (third-person camera // sits behind the character) and takes the first real hit after it. bool TryRaycast(Ray ray, out RaycastHit result) { Transform self = NetworkClient.localPlayer != null ? NetworkClient.localPlayer.transform : null; var allHits = Physics.RaycastAll(ray, range, ~0, QueryTriggerInteraction.Ignore); System.Array.Sort(allHits, (a, b) => a.distance.CompareTo(b.distance)); foreach (var h in allHits) { if (self != null && h.collider.transform.IsChildOf(self)) continue; if (h.collider.name == "DroppedMagazine") continue; result = h; return true; } result = default; return false; } void AddLog(string line) { log.Add(line); if (log.Count > 8) log.RemoveAt(0); Debug.Log("[Shooter] " + line); } void OnGUI() { if (RangeIntroCinematic.IsPlaying) return; if (hudStyle == null) { hudStyle = new GUIStyle(GUI.skin.label) { fontSize = 15, richText = true }; hudStyle.normal.textColor = Color.white; hitmarkerStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold }; } DrawCrosshair(); float accuracy = shotsFired > 0 ? 100f * hits / shotsFired : 0f; float hsRate = hits > 0 ? 100f * headshots / hits : 0f; string ammoText = reloading ? $"RELOADING {reloadDoneAt - Time.time:0.0}s" : $"{ammo} / {magazineSize}"; string state = CanUseMouse() ? "READY" : CanUseGamepad() && Gamepad.current != null ? "READY (controller)" : "INPUT BLOCKED (Esc/chat)"; string device = usingGamepad ? "Controller" : "Mouse & Keyboard"; string controls = usingGamepad ? "RT fire (hold = spray) · X/□ reload · Right stick aim" : "LMB fire (hold = spray) · R reload · Esc cursor"; var text = new System.Text.StringBuilder(); text.AppendLine($"{weaponName} {state} Input: {device}"); text.AppendLine($"Ammo: {ammoText}"); text.AppendLine($"Shots: {shotsFired} Hits: {hits} Accuracy: {accuracy:0}%"); text.AppendLine($"Headshots: {headshots} ({hsRate:0}%) Kills: {kills}"); text.AppendLine($"Moving: {(isMoving ? "YES (inaccurate)" : "no")} Spray #{sprayIndex} Last spread: {lastSpread:0.0}°"); text.AppendLine(controls); text.AppendLine(); foreach (var line in log) text.AppendLine(line); var rect = new Rect(10, 10, 580, 320); GUI.color = new Color(0, 0, 0, 0.55f); GUI.DrawTexture(rect, Texture2D.whiteTexture); GUI.color = Color.white; GUI.Label(new Rect(rect.x + 8, rect.y + 6, rect.width - 16, rect.height - 12), text.ToString(), hudStyle); } void DrawCrosshair() { float cx = Screen.width * 0.5f; float cy = Screen.height * 0.5f; // Valorant-like: small center dot plus four short lines that push out with spread. float gap = 4f + (Mathf.Min(sprayIndex * bloomPerShot, maxBloom) + (isMoving ? movingInaccuracy : 0f)) * 3f; GUI.color = Color.cyan; GUI.DrawTexture(new Rect(cx - 1, cy - 1, 2, 2), Texture2D.whiteTexture); GUI.DrawTexture(new Rect(cx - 1, cy - gap - 6, 2, 6), Texture2D.whiteTexture); GUI.DrawTexture(new Rect(cx - 1, cy + gap, 2, 6), Texture2D.whiteTexture); GUI.DrawTexture(new Rect(cx - gap - 6, cy - 1, 6, 2), Texture2D.whiteTexture); GUI.DrawTexture(new Rect(cx + gap, cy - 1, 6, 2), Texture2D.whiteTexture); if (Time.time < hitmarkerUntil) { GUI.color = hitmarkerKill ? Color.red : hitmarkerHead ? Color.yellow : Color.white; hitmarkerStyle.fontSize = hitmarkerHead ? 28 : 20; GUI.Label(new Rect(cx - 30, cy - 30, 60, 60), "X", hitmarkerStyle); } GUI.color = Color.white; } }