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:
co-authored by
Claude Opus 5.5
parent
c674d76549
commit
5fc9ebf1e6
@@ -0,0 +1,431 @@
|
||||
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<string> log = new List<string>();
|
||||
|
||||
NetworkIdentity equippedPlayer;
|
||||
CharacterMovement movement;
|
||||
WeaponRig rig;
|
||||
float originalCameraDistance;
|
||||
AudioSource audioSource;
|
||||
|
||||
GUIStyle hudStyle;
|
||||
GUIStyle hitmarkerStyle;
|
||||
|
||||
void Start()
|
||||
{
|
||||
ammo = magazineSize;
|
||||
audioSource = gameObject.AddComponent<AudioSource>();
|
||||
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<CharacterMovement>();
|
||||
if (movement != null)
|
||||
{
|
||||
originalCameraDistance = movement.cameraDistance;
|
||||
movement.cameraDistance = aimCameraDistance;
|
||||
movement.cameraShoulderOffset = shoulderOffset;
|
||||
movement.faceCameraYaw = true;
|
||||
}
|
||||
|
||||
var animator = player.GetComponentInChildren<Animator>();
|
||||
if (animator != null && animator.isHuman)
|
||||
rig = animator.gameObject.AddComponent<WeaponRig>();
|
||||
}
|
||||
|
||||
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<ShootingTargetHitbox>();
|
||||
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
|
||||
? $"<color=orange>RELOADING {reloadDoneAt - Time.time:0.0}s</color>"
|
||||
: $"{ammo} / {magazineSize}";
|
||||
string state = CanUseMouse() ? "<color=lime>READY</color>"
|
||||
: CanUseGamepad() && Gamepad.current != null ? "<color=lime>READY (controller)</color>"
|
||||
: "<color=red>INPUT BLOCKED (Esc/chat)</color>";
|
||||
string device = usingGamepad ? "Controller" : "Mouse & Keyboard";
|
||||
string controls = usingGamepad
|
||||
? "<i>RT fire (hold = spray) · X/□ reload · Right stick aim</i>"
|
||||
: "<i>LMB fire (hold = spray) · R reload · Esc cursor</i>";
|
||||
|
||||
var text = new System.Text.StringBuilder();
|
||||
text.AppendLine($"<b>{weaponName}</b> {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 ? "<color=orange>YES (inaccurate)</color>" : "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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user