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
+431
View File
@@ -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;
}
}