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,26 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Tiny self-animating effect: squeezes the object's X/Z to nothing (and stretches Y) over
|
||||
// `duration`, then destroys it. Used for the target spawn beam.
|
||||
public class FxShrink : MonoBehaviour
|
||||
{
|
||||
public float duration = 0.45f;
|
||||
public float stretch = 1.6f;
|
||||
|
||||
Vector3 startScale;
|
||||
float startTime;
|
||||
|
||||
void Start()
|
||||
{
|
||||
startScale = transform.localScale;
|
||||
startTime = Time.time;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
float t = Mathf.Clamp01((Time.time - startTime) / duration);
|
||||
float squeeze = 1f - t * t;
|
||||
transform.localScale = new Vector3(startScale.x * squeeze, startScale.y * Mathf.Lerp(1f, stretch, t), startScale.z * squeeze);
|
||||
if (t >= 1f) Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f748ccac31f7bf4f8fccf40e16d32ad
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4a1f90128165624d8edd144bfb81070
|
||||
@@ -0,0 +1,373 @@
|
||||
using System.Collections.Generic;
|
||||
using Mirror;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
// Code-driven intro cutscene for the practice range (no Timeline assets):
|
||||
// A 0.0–3.2s Establishing: high crane sweep over the map, title card + boom.
|
||||
// B 3.2–6.0s Targets online: low tracking shot down the street while bots beam in one by one.
|
||||
// C 6.0–8.2s Hero: camera starts in front of the player, orbits over the right shoulder and
|
||||
// blends into the exact gameplay camera while the rifle is raised.
|
||||
// Outro Letterbox slides away, "GO" stinger, HUD returns, control handed back.
|
||||
// Space / Enter / gamepad A skips; F1 replays. Runs after CharacterMovement so it owns the camera.
|
||||
[DefaultExecutionOrder(200)]
|
||||
public class RangeIntroCinematic : MonoBehaviour
|
||||
{
|
||||
public static bool IsPlaying { get; private set; }
|
||||
|
||||
const float ShotAEnd = 3.2f;
|
||||
const float ShotBEnd = 6.0f;
|
||||
const float ShotCEnd = 8.2f;
|
||||
const float OutroLength = 1.0f;
|
||||
const float CutDip = 0.16f;
|
||||
|
||||
static readonly Vector3 ShotBStart = new Vector3(2.2f, 0.35f, 16f);
|
||||
static readonly Vector3 ShotBEndPos = new Vector3(-1.2f, 1.8f, 10f);
|
||||
|
||||
List<ShootingTarget> targets;
|
||||
float[] popTimes;
|
||||
int poppedCount;
|
||||
|
||||
Camera cam;
|
||||
float originalFov = 60f;
|
||||
float t;
|
||||
float prevT;
|
||||
float releasedAt = -10f;
|
||||
bool equipStarted;
|
||||
WeaponRig rig;
|
||||
CharacterMovement movement;
|
||||
|
||||
AudioSource sfx;
|
||||
AudioSource blipSource;
|
||||
|
||||
GUIStyle titleStyle;
|
||||
GUIStyle subStyle;
|
||||
GUIStyle captionStyle;
|
||||
GUIStyle goStyle;
|
||||
|
||||
public void Begin(List<ShootingTarget> rangeTargets)
|
||||
{
|
||||
targets = rangeTargets;
|
||||
|
||||
if (sfx == null)
|
||||
{
|
||||
sfx = gameObject.AddComponent<AudioSource>();
|
||||
sfx.playOnAwake = false;
|
||||
blipSource = gameObject.AddComponent<AudioSource>();
|
||||
blipSource.playOnAwake = false;
|
||||
}
|
||||
|
||||
// Bots beam in nearest-to-camera first, so the wave rolls away down the street.
|
||||
targets.Sort((a, b) => Vector3.Distance(ShotBStart, a.transform.position)
|
||||
.CompareTo(Vector3.Distance(ShotBStart, b.transform.position)));
|
||||
popTimes = new float[targets.Count];
|
||||
for (int i = 0; i < targets.Count; i++)
|
||||
{
|
||||
popTimes[i] = Mathf.Lerp(ShotAEnd + 0.35f, ShotBEnd - 0.55f, targets.Count > 1 ? i / (float)(targets.Count - 1) : 0f);
|
||||
targets[i].HideForIntro();
|
||||
}
|
||||
poppedCount = 0;
|
||||
|
||||
cam = Camera.main;
|
||||
if (cam != null) originalFov = cam.fieldOfView;
|
||||
|
||||
t = 0f;
|
||||
prevT = 0f;
|
||||
releasedAt = -10f;
|
||||
equipStarted = false;
|
||||
IsPlaying = true;
|
||||
CharacterMovement.CinematicControl = true;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (IsPlaying)
|
||||
{
|
||||
IsPlaying = false;
|
||||
CharacterMovement.CinematicControl = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Crossed(float time) => prevT < time && t >= time;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!IsPlaying)
|
||||
{
|
||||
var k = Keyboard.current;
|
||||
if (k != null && k.f1Key.wasPressedThisFrame && Time.time - releasedAt > OutroLength)
|
||||
Begin(targets);
|
||||
return;
|
||||
}
|
||||
|
||||
prevT = t;
|
||||
t += Time.deltaTime;
|
||||
|
||||
if (movement == null && NetworkClient.localPlayer != null)
|
||||
movement = NetworkClient.localPlayer.GetComponent<CharacterMovement>();
|
||||
if (rig == null) rig = FindAnyObjectByType<WeaponRig>();
|
||||
if (rig != null && !equipStarted) rig.SetLowered();
|
||||
|
||||
if (Crossed(0.55f)) sfx.PlayOneShot(ShootingFx.Boom, 0.9f);
|
||||
if (Crossed(ShotAEnd - 0.35f) || Crossed(ShotBEnd - 0.35f)) sfx.PlayOneShot(ShootingFx.Whoosh, 0.8f);
|
||||
|
||||
for (int i = poppedCount; i < targets.Count && t >= popTimes[i]; i++)
|
||||
{
|
||||
targets[i].PopIn();
|
||||
blipSource.pitch = 1f + i * 0.08f;
|
||||
blipSource.PlayOneShot(ShootingFx.Blip, 0.7f);
|
||||
poppedCount = i + 1;
|
||||
}
|
||||
|
||||
if (!equipStarted && t >= ShotBEnd + 0.5f && rig != null)
|
||||
{
|
||||
equipStarted = true;
|
||||
rig.PlayEquip(1.1f);
|
||||
sfx.PlayOneShot(ShootingFx.MagIn, 0.8f);
|
||||
}
|
||||
|
||||
if (WantsSkip()) Skip();
|
||||
else if (t >= ShotCEnd) Release();
|
||||
}
|
||||
|
||||
static bool WantsSkip()
|
||||
{
|
||||
var k = Keyboard.current;
|
||||
var pad = Gamepad.current;
|
||||
return (k != null && (k.spaceKey.wasPressedThisFrame || k.enterKey.wasPressedThisFrame))
|
||||
|| (pad != null && (pad.buttonSouth.wasPressedThisFrame || pad.startButton.wasPressedThisFrame));
|
||||
}
|
||||
|
||||
void Skip()
|
||||
{
|
||||
for (int i = poppedCount; i < targets.Count; i++) targets[i].PopIn();
|
||||
poppedCount = targets.Count;
|
||||
if (rig != null && !equipStarted) rig.PlayEquip(0.35f);
|
||||
equipStarted = true;
|
||||
Release();
|
||||
}
|
||||
|
||||
void Release()
|
||||
{
|
||||
IsPlaying = false;
|
||||
CharacterMovement.CinematicControl = false;
|
||||
releasedAt = Time.time;
|
||||
if (cam != null) cam.fieldOfView = originalFov;
|
||||
sfx.PlayOneShot(ShootingFx.GoStinger, 0.8f);
|
||||
}
|
||||
|
||||
// ---- Camera ------------------------------------------------------------------------
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (!IsPlaying) return;
|
||||
if (cam == null) cam = Camera.main;
|
||||
if (cam == null) return;
|
||||
|
||||
Vector3 pos;
|
||||
Quaternion rot;
|
||||
float fov;
|
||||
float shake;
|
||||
|
||||
if (t < ShotAEnd) ShotA(t / ShotAEnd, out pos, out rot, out fov, out shake);
|
||||
else if (t < ShotBEnd) ShotB((t - ShotAEnd) / (ShotBEnd - ShotAEnd), out pos, out rot, out fov, out shake);
|
||||
else ShotC((t - ShotBEnd) / (ShotCEnd - ShotBEnd), out pos, out rot, out fov, out shake);
|
||||
|
||||
// Subtle handheld drift.
|
||||
float nx = (Mathf.PerlinNoise(Time.time * 0.7f, 0.1f) - 0.5f) * 2f;
|
||||
float ny = (Mathf.PerlinNoise(0.3f, Time.time * 0.6f) - 0.5f) * 2f;
|
||||
rot *= Quaternion.Euler(ny * shake, nx * shake, 0f);
|
||||
|
||||
cam.transform.SetPositionAndRotation(pos, rot);
|
||||
cam.fieldOfView = fov;
|
||||
}
|
||||
|
||||
// Indoor crane sweep under the roof trusses (below the pendant lamps at 5.6 m), easing from
|
||||
// wide to a slightly tighter frame.
|
||||
static void ShotA(float u, out Vector3 pos, out Quaternion rot, out float fov, out float shake)
|
||||
{
|
||||
float e = Mathf.SmoothStep(0f, 1f, u);
|
||||
float yaw = Mathf.Lerp(215f, 150f, e);
|
||||
float radius = Mathf.Lerp(15f, 11f, e);
|
||||
float height = Mathf.Lerp(4.8f, 3.2f, e);
|
||||
pos = Quaternion.Euler(0f, yaw, 0f) * new Vector3(0f, height, -radius);
|
||||
rot = Quaternion.LookRotation(new Vector3(0f, 0.5f, Mathf.Lerp(4f, 0f, e)) - pos);
|
||||
fov = Mathf.Lerp(55f, 48f, e);
|
||||
shake = 0.25f;
|
||||
}
|
||||
|
||||
// Low dolly down the street with a dutch angle that levels out and a zoom-out.
|
||||
static void ShotB(float u, out Vector3 pos, out Quaternion rot, out float fov, out float shake)
|
||||
{
|
||||
float e = EaseInOutCubic(u);
|
||||
pos = Vector3.Lerp(ShotBStart, ShotBEndPos, e);
|
||||
Vector3 look = Vector3.Lerp(new Vector3(0f, 1f, 2f), new Vector3(0f, 1f, -6f), e);
|
||||
rot = Quaternion.LookRotation(look - pos) * Quaternion.Euler(0f, 0f, Mathf.Lerp(7f, 0f, e));
|
||||
fov = Mathf.Lerp(36f, 55f, e);
|
||||
shake = 0.35f;
|
||||
}
|
||||
|
||||
// Starts on the player's face, orbits around their right side, then settles exactly into
|
||||
// the gameplay camera so the hand-off has no pop.
|
||||
void ShotC(float u, out Vector3 pos, out Quaternion rot, out float fov, out float shake)
|
||||
{
|
||||
shake = Mathf.Lerp(0.3f, 0f, u);
|
||||
fov = Mathf.Lerp(42f, originalFov, Mathf.SmoothStep(0f, 1f, u));
|
||||
|
||||
if (movement == null)
|
||||
{
|
||||
// No local player yet: slow push toward spawn A instead.
|
||||
pos = Vector3.Lerp(new Vector3(0f, 3f, -4f), new Vector3(0f, 2.2f, -9f), Mathf.SmoothStep(0f, 1f, u));
|
||||
rot = Quaternion.LookRotation(new Vector3(0f, 1.2f, -14f) - pos);
|
||||
return;
|
||||
}
|
||||
|
||||
Transform p = movement.transform;
|
||||
Vector3 face = p.position + Vector3.up * 1.55f;
|
||||
float e = EaseInOutCubic(Mathf.Clamp01(u / 0.85f));
|
||||
float angle = Mathf.Lerp(0f, 160f, e);
|
||||
Vector3 dir = Quaternion.AngleAxis(angle, Vector3.up) * p.forward;
|
||||
float radius = Mathf.Lerp(1.8f, 3f, e);
|
||||
Vector3 orbitPos = face + dir * radius + Vector3.up * Mathf.Lerp(-0.15f, 0.6f, e);
|
||||
Vector3 lookTarget = Vector3.Lerp(face, face + p.forward * 8f, e);
|
||||
Quaternion orbitRot = Quaternion.LookRotation(lookTarget - orbitPos);
|
||||
|
||||
movement.GetGameplayCameraPose(out Vector3 gamePos, out Quaternion gameRot);
|
||||
float blend = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(0.55f, 1f, u));
|
||||
pos = Vector3.Lerp(orbitPos, gamePos, blend);
|
||||
rot = Quaternion.Slerp(orbitRot, gameRot, blend);
|
||||
}
|
||||
|
||||
// Classic smoothstep: 0 below edge0, 1 above edge1, eased in between.
|
||||
// (Mathf.SmoothStep(a, b, x) interpolates *between* a and b instead.)
|
||||
static float Smooth(float edge0, float edge1, float x) => Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(edge0, edge1, x));
|
||||
|
||||
static float EaseInOutCubic(float x) => x < 0.5f ? 4f * x * x * x : 1f - Mathf.Pow(-2f * x + 2f, 3f) / 2f;
|
||||
|
||||
// ---- Overlay -----------------------------------------------------------------------
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
float sinceRelease = Time.time - releasedAt;
|
||||
bool inOutro = !IsPlaying && sinceRelease < OutroLength;
|
||||
if (!IsPlaying && !inOutro) return;
|
||||
|
||||
EnsureStyles();
|
||||
float w = Screen.width;
|
||||
float h = Screen.height;
|
||||
|
||||
// Fade from black at the start, quick dips at the cuts, white flash on release.
|
||||
float black = IsPlaying
|
||||
? Mathf.Max(1f - t / 0.6f, Dip(ShotAEnd), Dip(ShotBEnd))
|
||||
: 0f;
|
||||
if (black > 0f) Fill(new Rect(0, 0, w, h), new Color(0f, 0f, 0f, black));
|
||||
|
||||
// Letterbox bars: slide in over 0.5s, slide out during the outro.
|
||||
float bar = h * 0.12f * (IsPlaying ? Mathf.SmoothStep(0f, 1f, t / 0.5f) : 1f - Mathf.SmoothStep(0f, 1f, sinceRelease / 0.45f));
|
||||
Fill(new Rect(0, 0, w, bar), Color.black);
|
||||
Fill(new Rect(0, h - bar, w, bar), Color.black);
|
||||
|
||||
if (IsPlaying)
|
||||
{
|
||||
DrawTitle(w, h);
|
||||
DrawCaptions(w, h, bar);
|
||||
GUI.color = new Color(1f, 1f, 1f, 0.55f);
|
||||
GUI.Label(new Rect(w - 260, h - bar + bar * 0.5f - 12, 240, 24), "SPACE / (A) SKIP", captionStyle);
|
||||
}
|
||||
else
|
||||
{
|
||||
float flash = 1f - Mathf.Clamp01(sinceRelease / 0.18f);
|
||||
if (flash > 0f) Fill(new Rect(0, 0, w, h), new Color(1f, 1f, 1f, flash * 0.6f));
|
||||
|
||||
// "GO" punches in big and settles, then fades.
|
||||
float punch = Mathf.Lerp(1.6f, 1f, Mathf.SmoothStep(0f, 1f, sinceRelease / 0.25f));
|
||||
float alpha = 1f - Smooth(0.55f, 1f, sinceRelease / OutroLength);
|
||||
goStyle.fontSize = Mathf.RoundToInt(h * 0.16f * punch);
|
||||
GUI.color = new Color(1f, 0.9f, 0.35f, alpha);
|
||||
GUI.Label(new Rect(0, 0, w, h), "GO", goStyle);
|
||||
}
|
||||
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
|
||||
float Dip(float cutTime) => 1f - Mathf.Clamp01(Mathf.Abs(t - cutTime) / CutDip);
|
||||
|
||||
// "PRACTICE RANGE" types on letter by letter, each letter dropping into place.
|
||||
void DrawTitle(float w, float h)
|
||||
{
|
||||
const string title = "PRACTICE RANGE";
|
||||
const float revealStart = 0.55f;
|
||||
const float perLetter = 0.055f;
|
||||
float fadeOut = 1f - Smooth(ShotAEnd - 0.5f, ShotAEnd - 0.1f, t);
|
||||
if (t < revealStart || fadeOut <= 0f) return;
|
||||
|
||||
titleStyle.fontSize = Mathf.RoundToInt(h * 0.085f);
|
||||
float spacing = Mathf.Lerp(h * 0.03f, h * 0.012f, Mathf.SmoothStep(0f, 1f, (t - revealStart) / 1.4f));
|
||||
var sizes = new Vector2[title.Length];
|
||||
float total = 0f;
|
||||
for (int i = 0; i < title.Length; i++)
|
||||
{
|
||||
sizes[i] = titleStyle.CalcSize(new GUIContent(title[i].ToString()));
|
||||
total += sizes[i].x + spacing;
|
||||
}
|
||||
|
||||
float x = (w - total) * 0.5f;
|
||||
float y = h * 0.38f;
|
||||
for (int i = 0; i < title.Length; i++)
|
||||
{
|
||||
float lt = Mathf.Clamp01((t - revealStart - i * perLetter) / 0.25f);
|
||||
float drop = (1f - Mathf.SmoothStep(0f, 1f, lt)) * -h * 0.04f;
|
||||
GUI.color = new Color(1f, 1f, 1f, lt * fadeOut);
|
||||
GUI.Label(new Rect(x, y + drop, sizes[i].x + 4, sizes[i].y), title[i].ToString(), titleStyle);
|
||||
x += sizes[i].x + spacing;
|
||||
}
|
||||
|
||||
float subAlpha = Mathf.Clamp01((t - 1.4f) / 0.4f) * fadeOut;
|
||||
subStyle.fontSize = Mathf.RoundToInt(h * 0.028f);
|
||||
GUI.color = new Color(0.55f, 0.9f, 1f, subAlpha);
|
||||
GUI.Label(new Rect(0, y + h * 0.1f, w, h * 0.05f), "PORTAL TEST DESTINATION · VANDAL CALIBRATION", subStyle);
|
||||
}
|
||||
|
||||
// Lower-third captions for shots B and C.
|
||||
void DrawCaptions(float w, float h, float bar)
|
||||
{
|
||||
captionStyle.fontSize = Mathf.RoundToInt(h * 0.03f);
|
||||
float y = h - bar - h * 0.07f;
|
||||
|
||||
if (t >= ShotAEnd + 0.2f && t < ShotBEnd)
|
||||
{
|
||||
float a = Mathf.Clamp01((t - ShotAEnd - 0.2f) / 0.3f) * (1f - Mathf.Clamp01((t - ShotBEnd + 0.3f) / 0.3f));
|
||||
GUI.color = new Color(0.55f, 0.9f, 1f, a);
|
||||
GUI.Label(new Rect(w * 0.06f, y, w, h * 0.05f), $"TARGETS ONLINE {poppedCount:00} / {targets.Count:00}", captionStyle);
|
||||
}
|
||||
else if (t >= ShotBEnd + 0.3f)
|
||||
{
|
||||
float a = Mathf.Clamp01((t - ShotBEnd - 0.3f) / 0.3f) * (1f - Mathf.Clamp01((t - ShotCEnd + 0.4f) / 0.3f));
|
||||
var auth = FindAnyObjectByType<ChatAuthenticator>();
|
||||
string playerName = auth != null && !string.IsNullOrEmpty(auth.playerName) ? auth.playerName.ToUpperInvariant() : "AGENT";
|
||||
GUI.color = new Color(1f, 1f, 1f, a);
|
||||
GUI.Label(new Rect(w * 0.06f, y - h * 0.045f, w, h * 0.05f), playerName, captionStyle);
|
||||
GUI.color = new Color(1f, 0.9f, 0.35f, a);
|
||||
GUI.Label(new Rect(w * 0.06f, y, w, h * 0.05f), "VANDAL · 25 RDS · HEADSHOT 160", captionStyle);
|
||||
}
|
||||
}
|
||||
|
||||
void EnsureStyles()
|
||||
{
|
||||
if (titleStyle != null) return;
|
||||
titleStyle = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.UpperLeft };
|
||||
titleStyle.normal.textColor = Color.white;
|
||||
subStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
|
||||
subStyle.normal.textColor = Color.white;
|
||||
captionStyle = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft };
|
||||
captionStyle.normal.textColor = Color.white;
|
||||
goStyle = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter };
|
||||
goStyle.normal.textColor = Color.white;
|
||||
}
|
||||
|
||||
static void Fill(Rect r, Color c)
|
||||
{
|
||||
GUI.color = c;
|
||||
GUI.DrawTexture(r, Texture2D.whiteTexture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ab19d1d24061ade41b51644c02ff51e0
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// Procedural "assets" for the shooting prototype: tinted/emissive materials cloned from the
|
||||
// render pipeline's default material (so they work in URP without shipping .mat files), and
|
||||
// synthesized sound effects built with AudioClip.Create (no audio files needed).
|
||||
public static class ShootingFx
|
||||
{
|
||||
static Material baseMaterial;
|
||||
static readonly Dictionary<(Color, float), Material> materialCache = new Dictionary<(Color, float), Material>();
|
||||
|
||||
public static Material GetMaterial(Color color, float emission = 0f)
|
||||
{
|
||||
if (materialCache.TryGetValue((color, emission), out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
if (baseMaterial == null)
|
||||
{
|
||||
var probe = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
baseMaterial = probe.GetComponent<Renderer>().sharedMaterial;
|
||||
Object.Destroy(probe);
|
||||
}
|
||||
|
||||
var mat = new Material(baseMaterial) { color = color };
|
||||
if (emission > 0f)
|
||||
{
|
||||
mat.EnableKeyword("_EMISSION");
|
||||
mat.SetColor("_EmissionColor", color * emission);
|
||||
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
|
||||
}
|
||||
|
||||
materialCache[(color, emission)] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Primitive with its collider stripped — for visuals that must never block raycasts.
|
||||
public static GameObject CreateVisual(PrimitiveType type, string name, Transform parent,
|
||||
Vector3 localPosition, Vector3 localScale, Material material, Vector3 localEuler = default)
|
||||
{
|
||||
var go = GameObject.CreatePrimitive(type);
|
||||
go.name = name;
|
||||
Object.Destroy(go.GetComponent<Collider>());
|
||||
go.transform.SetParent(parent, false);
|
||||
go.transform.localPosition = localPosition;
|
||||
go.transform.localRotation = Quaternion.Euler(localEuler);
|
||||
go.transform.localScale = localScale;
|
||||
var renderer = go.GetComponent<Renderer>();
|
||||
renderer.sharedMaterial = material;
|
||||
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
return go;
|
||||
}
|
||||
|
||||
// ---- Effects -------------------------------------------------------------------------
|
||||
|
||||
public static void SpawnTracer(Vector3 from, Vector3 to)
|
||||
{
|
||||
Vector3 dir = to - from;
|
||||
float length = dir.magnitude;
|
||||
if (length < 0.05f) return;
|
||||
|
||||
var tracer = CreateVisual(PrimitiveType.Cube, "Tracer", null, from + dir * 0.5f,
|
||||
new Vector3(0.015f, 0.015f, length), GetMaterial(new Color(1f, 0.85f, 0.4f), 4f));
|
||||
tracer.transform.rotation = Quaternion.LookRotation(dir);
|
||||
Object.Destroy(tracer, 0.05f);
|
||||
}
|
||||
|
||||
public static void SpawnImpact(Vector3 point, Vector3 normal, bool fleshHit)
|
||||
{
|
||||
var decal = CreateVisual(PrimitiveType.Cube, "BulletHole", null, point + normal * 0.005f,
|
||||
new Vector3(0.07f, 0.07f, 0.01f), GetMaterial(new Color(0.05f, 0.05f, 0.05f)));
|
||||
decal.transform.rotation = Quaternion.LookRotation(normal);
|
||||
Object.Destroy(decal, fleshHit ? 0.1f : 4f);
|
||||
|
||||
Color sparkColor = fleshHit ? new Color(1f, 0.2f, 0.15f) : new Color(1f, 0.75f, 0.3f);
|
||||
var sparkMat = GetMaterial(sparkColor, 3f);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var spark = CreateVisual(PrimitiveType.Cube, "Spark", null, point + normal * 0.03f,
|
||||
Vector3.one * 0.025f, sparkMat, Random.insideUnitSphere * 180f);
|
||||
var body = spark.AddComponent<Rigidbody>();
|
||||
body.useGravity = true;
|
||||
body.linearVelocity = (normal + Random.insideUnitSphere * 0.8f).normalized * Random.Range(2f, 4.5f);
|
||||
Object.Destroy(spark, Random.Range(0.25f, 0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical light column that squeezes away — "target materialized" effect.
|
||||
public static void SpawnBeam(Vector3 groundPoint)
|
||||
{
|
||||
var beam = CreateVisual(PrimitiveType.Cylinder, "SpawnBeam", null, groundPoint + Vector3.up * 1.5f,
|
||||
new Vector3(0.9f, 1.5f, 0.9f), GetMaterial(new Color(0.35f, 0.9f, 1f), 3f));
|
||||
beam.AddComponent<FxShrink>();
|
||||
}
|
||||
|
||||
// ---- Sounds --------------------------------------------------------------------------
|
||||
|
||||
const int SampleRate = 44100;
|
||||
static AudioClip gunshot, emptyClick, magOut, magIn, hitBody, hitHead, killConfirm;
|
||||
|
||||
public static AudioClip Gunshot => gunshot ??= Synth("Gunshot", 0.28f, (t, n) =>
|
||||
n * Mathf.Exp(-t * 28f) * 0.9f
|
||||
+ Mathf.Sin(2f * Mathf.PI * 95f * t) * Mathf.Exp(-t * 18f) * 0.8f
|
||||
+ Mathf.Sin(2f * Mathf.PI * 190f * t) * Mathf.Exp(-t * 40f) * 0.3f);
|
||||
|
||||
public static AudioClip EmptyClick => emptyClick ??= Synth("EmptyClick", 0.05f, (t, n) =>
|
||||
(n * 0.5f + Mathf.Sin(2f * Mathf.PI * 3200f * t)) * Mathf.Exp(-t * 120f) * 0.5f);
|
||||
|
||||
public static AudioClip MagOut => magOut ??= Synth("MagOut", 0.12f, (t, n) =>
|
||||
(n * 0.6f + Mathf.Sin(2f * Mathf.PI * 700f * t) * 0.4f) * Mathf.Exp(-t * 45f) * 0.6f);
|
||||
|
||||
public static AudioClip MagIn => magIn ??= Synth("MagIn", 0.15f, (t, n) =>
|
||||
(n * 0.5f + Mathf.Sin(2f * Mathf.PI * 1100f * t) * 0.5f) * Mathf.Exp(-t * 35f) * 0.7f
|
||||
+ (t > 0.07f ? (n * 0.6f + Mathf.Sin(2f * Mathf.PI * 1500f * t) * 0.4f) * Mathf.Exp(-(t - 0.07f) * 50f) * 0.7f : 0f));
|
||||
|
||||
public static AudioClip HitBody => hitBody ??= Synth("HitBody", 0.06f, (t, n) =>
|
||||
Mathf.Sin(2f * Mathf.PI * 1700f * t) * Mathf.Exp(-t * 60f) * 0.35f);
|
||||
|
||||
public static AudioClip HitHead => hitHead ??= Synth("HitHead", 0.18f, (t, n) =>
|
||||
(Mathf.Sin(2f * Mathf.PI * 2600f * t) + Mathf.Sin(2f * Mathf.PI * 3900f * t) * 0.5f) * Mathf.Exp(-t * 22f) * 0.35f);
|
||||
|
||||
public static AudioClip KillConfirm => killConfirm ??= Synth("Kill", 0.22f, (t, n) =>
|
||||
Mathf.Sin(2f * Mathf.PI * (t < 0.08f ? 880f : 1320f) * t) * Mathf.Exp(-(t % 0.08f) * 18f) * 0.35f);
|
||||
|
||||
static AudioClip whoosh, boom, blip, goStinger;
|
||||
|
||||
// Filtered noise that swells and fades, with the filter opening up (camera fly-by).
|
||||
public static AudioClip Whoosh => whoosh ??= SynthWhoosh();
|
||||
|
||||
static AudioClip SynthWhoosh()
|
||||
{
|
||||
float lowpass = 0f;
|
||||
return Synth("Whoosh", 0.9f, (t, n) =>
|
||||
{
|
||||
float cutoff = Mathf.Lerp(0.02f, 0.25f, Mathf.Sin(Mathf.PI * Mathf.Clamp01(t / 0.9f)));
|
||||
lowpass += (n - lowpass) * cutoff;
|
||||
float env = Mathf.Sin(Mathf.PI * Mathf.Clamp01(t / 0.9f));
|
||||
return lowpass * env * env * 1.8f;
|
||||
});
|
||||
}
|
||||
|
||||
// Sub-bass drop with a noisy transient (title hit).
|
||||
public static AudioClip Boom => boom ??= Synth("Boom", 1.6f, (t, n) =>
|
||||
{
|
||||
float freq = Mathf.Lerp(90f, 38f, Mathf.Clamp01(t / 1.2f));
|
||||
return Mathf.Sin(2f * Mathf.PI * freq * t) * Mathf.Exp(-t * 2.2f) * 0.9f
|
||||
+ n * Mathf.Exp(-t * 30f) * 0.5f;
|
||||
});
|
||||
|
||||
// Short digital blip — pitch it up per target for a rising "boot sequence".
|
||||
public static AudioClip Blip => blip ??= Synth("Blip", 0.12f, (t, n) =>
|
||||
(Mathf.Sin(2f * Mathf.PI * 880f * t) + Mathf.Sin(2f * Mathf.PI * 1760f * t) * 0.3f) * Mathf.Exp(-t * 30f) * 0.35f);
|
||||
|
||||
// Bright major chord stab for "GO".
|
||||
public static AudioClip GoStinger => goStinger ??= Synth("Go", 0.8f, (t, n) =>
|
||||
{
|
||||
float env = Mathf.Min(1f, t * 60f) * Mathf.Exp(-t * 4f);
|
||||
return (Mathf.Sin(2f * Mathf.PI * 523.25f * t) + Mathf.Sin(2f * Mathf.PI * 659.25f * t)
|
||||
+ Mathf.Sin(2f * Mathf.PI * 783.99f * t) + Mathf.Sin(2f * Mathf.PI * 1046.5f * t) * 0.5f) * env * 0.18f
|
||||
+ n * Mathf.Exp(-t * 40f) * 0.2f;
|
||||
});
|
||||
|
||||
delegate float SampleFunc(float time, float noise);
|
||||
|
||||
// Samples are generated in order, so a SampleFunc may keep state (e.g. a filter) in a closure.
|
||||
static AudioClip Synth(string name, float duration, SampleFunc func)
|
||||
{
|
||||
int count = Mathf.CeilToInt(duration * SampleRate);
|
||||
var data = new float[count];
|
||||
var rng = new System.Random(name.GetHashCode());
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
float noise = (float)(rng.NextDouble() * 2.0 - 1.0);
|
||||
data[i] = Mathf.Clamp(func(i / (float)SampleRate, noise), -1f, 1f);
|
||||
}
|
||||
|
||||
var clip = AudioClip.Create(name, count, 1, SampleRate, false);
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fecd731e2e4aac4a9eb7dff9916965a
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
// Auto-installs the shooting prototype (targets + HitscanShooter) whenever the
|
||||
// PortalTestDestination scene loads, so no scene/prefab edits are needed. If a
|
||||
// ShootingRange is already placed in the scene by hand, the bootstrap does nothing.
|
||||
public class ShootingRange : MonoBehaviour
|
||||
{
|
||||
const string SceneName = "PortalTestDestination";
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
static void RegisterBootstrap()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
}
|
||||
|
||||
static void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
if (scene.name != SceneName) return;
|
||||
if (FindAnyObjectByType<ShootingRange>() != null) return;
|
||||
|
||||
var go = new GameObject("ShootingRange");
|
||||
SceneManager.MoveGameObjectToScene(go, scene);
|
||||
go.AddComponent<ShootingRange>();
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (GetComponent<HitscanShooter>() == null)
|
||||
gameObject.AddComponent<HitscanShooter>();
|
||||
|
||||
var targets = SpawnTargets();
|
||||
gameObject.AddComponent<RangeIntroCinematic>().Begin(targets);
|
||||
}
|
||||
|
||||
// Positions match the greybox map from SetupPortalTestDestinationScene: central street
|
||||
// along Z (spawns at z = ±14), buildings at x = ±13, crates at (-2,-4) and (2,4).
|
||||
List<ShootingTarget> SpawnTargets()
|
||||
{
|
||||
var root = new GameObject("Targets").transform;
|
||||
root.SetParent(transform, false);
|
||||
var list = new List<ShootingTarget>();
|
||||
|
||||
// Static bots along the street, visible from either spawn.
|
||||
list.Add(ShootingTarget.Create(root, "Target_Street_1", new Vector3(0f, 0f, 1.5f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Street_2", new Vector3(0f, 0f, -1.5f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_L1", new Vector3(-6f, 0f, 8f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_R1", new Vector3(6f, 0f, 8f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_L2", new Vector3(-6f, 0f, -8f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Alley_R2", new Vector3(6f, 0f, -8f)));
|
||||
|
||||
// Inside the middle buildings (visible through the street-facing doors).
|
||||
list.Add(ShootingTarget.Create(root, "Target_Building_L", new Vector3(-13f, 0f, 0f)));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Building_R", new Vector3(13f, 0f, 0f)));
|
||||
|
||||
// Strafing bots for tracking practice.
|
||||
list.Add(ShootingTarget.Create(root, "Target_Strafe_N", new Vector3(0f, 0f, 7f), strafeDistance: 3f));
|
||||
list.Add(ShootingTarget.Create(root, "Target_Strafe_S", new Vector3(0f, 0f, -7f), strafeDistance: 3f));
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 01bca1bcc0ae1214c8fc09d4fd5178d3
|
||||
@@ -0,0 +1,276 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Greybox practice bot built from primitives: a capsule body and a sphere head, each with a
|
||||
// ShootingTargetHitbox so the shooter knows which zone was hit. Dies at 0 HP and respawns
|
||||
// after a delay, like the Valorant range bots. Purely local (not networked).
|
||||
// Procedural animations: flinch away from each hit, fall over on death, pop back up on respawn.
|
||||
public class ShootingTarget : MonoBehaviour
|
||||
{
|
||||
public float maxHealth = 150f;
|
||||
public float respawnDelay = 2f;
|
||||
|
||||
[Header("Strafing (0 = static)")]
|
||||
public float strafeDistance = 0f;
|
||||
public float strafeSpeed = 3f;
|
||||
|
||||
static readonly Color BodyColor = new Color(0.85f, 0.25f, 0.25f);
|
||||
static readonly Color HeadColor = new Color(0.95f, 0.8f, 0.3f);
|
||||
static readonly Color FlashColor = Color.white;
|
||||
const float DeathFallTime = 0.35f;
|
||||
const float RespawnPopTime = 0.25f;
|
||||
|
||||
public float Health { get; private set; }
|
||||
public bool IsDead { get; private set; }
|
||||
|
||||
Transform visual; // pivot at the feet; every animation rotates/scales this
|
||||
Renderer[] renderers;
|
||||
Material[][] originalMaterials;
|
||||
Material[][] flashMaterials;
|
||||
bool flashApplied;
|
||||
Renderer plateRenderer;
|
||||
bool hiddenForIntro;
|
||||
Material flashMaterial;
|
||||
Collider[] colliders;
|
||||
TextMesh healthLabel;
|
||||
Vector3 startPosition;
|
||||
float flashUntil;
|
||||
float respawnAt;
|
||||
float diedAt;
|
||||
float respawnedAt = -10f;
|
||||
|
||||
// Flinch: a tilt axis/amount that springs back to 0.
|
||||
Vector3 flinchAxis = Vector3.right;
|
||||
float flinchAngle;
|
||||
float flinchVelocity;
|
||||
Vector3 deathAxis = Vector3.right;
|
||||
|
||||
public static ShootingTarget Create(Transform parent, string name, Vector3 position, float strafeDistance = 0f)
|
||||
{
|
||||
var root = new GameObject(name);
|
||||
root.transform.SetParent(parent, false);
|
||||
root.transform.localPosition = position;
|
||||
|
||||
var target = root.AddComponent<ShootingTarget>();
|
||||
target.strafeDistance = strafeDistance;
|
||||
target.Build();
|
||||
return target;
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
flashMaterial = ShootingFx.GetMaterial(FlashColor, 1.5f);
|
||||
|
||||
visual = new GameObject("Visual").transform;
|
||||
visual.SetParent(transform, false);
|
||||
|
||||
// Real model (Quaternius CC0 practice dummy) with invisible box hitboxes fitted to its
|
||||
// head/torso; falls back to the capsule + sphere bot if the model is missing.
|
||||
var model = Resources.Load<GameObject>(ModelPath);
|
||||
if (model != null) BuildFromModel(model);
|
||||
else BuildPrimitive();
|
||||
|
||||
renderers = visual.GetComponentsInChildren<Renderer>();
|
||||
originalMaterials = new Material[renderers.Length][];
|
||||
flashMaterials = new Material[renderers.Length][];
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
originalMaterials[i] = renderers[i].sharedMaterials;
|
||||
flashMaterials[i] = new Material[originalMaterials[i].Length];
|
||||
for (int m = 0; m < flashMaterials[i].Length; m++) flashMaterials[i][m] = flashMaterial;
|
||||
}
|
||||
|
||||
// Base plate so the bot reads as a range dummy.
|
||||
var plate = ShootingFx.CreateVisual(PrimitiveType.Cylinder, "Base", transform, new Vector3(0f, 0.02f, 0f),
|
||||
new Vector3(0.9f, 0.02f, 0.9f), ShootingFx.GetMaterial(new Color(0.2f, 0.2f, 0.22f)));
|
||||
plateRenderer = plate.GetComponent<Renderer>();
|
||||
plateRenderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On;
|
||||
|
||||
var label = new GameObject("HealthLabel");
|
||||
label.transform.SetParent(transform, false);
|
||||
label.transform.localPosition = new Vector3(0f, 2.25f, 0f);
|
||||
healthLabel = label.AddComponent<TextMesh>();
|
||||
healthLabel.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
|
||||
label.GetComponent<MeshRenderer>().sharedMaterial = healthLabel.font.material;
|
||||
healthLabel.characterSize = 0.08f;
|
||||
healthLabel.fontSize = 48;
|
||||
healthLabel.anchor = TextAnchor.MiddleCenter;
|
||||
healthLabel.alignment = TextAlignment.Center;
|
||||
|
||||
colliders = visual.GetComponentsInChildren<Collider>();
|
||||
startPosition = transform.localPosition;
|
||||
Respawn();
|
||||
}
|
||||
|
||||
const string ModelPath = "Targets/PracticeDummy";
|
||||
|
||||
// Hitbox zones measured from the dummy mesh (1.85 m tall): torso 0.46–1.57 m, head 1.57–1.85 m.
|
||||
void BuildFromModel(GameObject model)
|
||||
{
|
||||
var instance = Instantiate(model, visual, false);
|
||||
instance.name = model.name;
|
||||
foreach (var c in instance.GetComponentsInChildren<Collider>()) Destroy(c);
|
||||
foreach (var r in instance.GetComponentsInChildren<Renderer>()) r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On;
|
||||
|
||||
AddBoxHitbox("Body", new Vector3(0f, 1.02f, 0f), new Vector3(0.66f, 1.1f, 0.5f), isHead: false);
|
||||
AddBoxHitbox("Head", new Vector3(0f, 1.71f, 0f), new Vector3(0.36f, 0.3f, 0.34f), isHead: true);
|
||||
}
|
||||
|
||||
void AddBoxHitbox(string name, Vector3 center, Vector3 size, bool isHead)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(visual, false);
|
||||
go.transform.localPosition = center;
|
||||
go.AddComponent<BoxCollider>().size = size;
|
||||
go.AddComponent<ShootingTargetHitbox>().Init(this, isHead);
|
||||
}
|
||||
|
||||
void BuildPrimitive()
|
||||
{
|
||||
var body = GameObject.CreatePrimitive(PrimitiveType.Capsule);
|
||||
body.name = "Body";
|
||||
body.transform.SetParent(visual, false);
|
||||
body.transform.localPosition = new Vector3(0f, 0.75f, 0f);
|
||||
body.transform.localScale = new Vector3(0.7f, 0.75f, 0.7f);
|
||||
body.AddComponent<ShootingTargetHitbox>().Init(this, isHead: false);
|
||||
body.GetComponent<Renderer>().sharedMaterial = ShootingFx.GetMaterial(BodyColor);
|
||||
|
||||
var head = GameObject.CreatePrimitive(PrimitiveType.Sphere);
|
||||
head.name = "Head";
|
||||
head.transform.SetParent(visual, false);
|
||||
head.transform.localPosition = new Vector3(0f, 1.73f, 0f);
|
||||
head.transform.localScale = Vector3.one * 0.36f;
|
||||
head.AddComponent<ShootingTargetHitbox>().Init(this, isHead: true);
|
||||
head.GetComponent<Renderer>().sharedMaterial = ShootingFx.GetMaterial(HeadColor);
|
||||
}
|
||||
|
||||
void ApplyFlash(bool flashing)
|
||||
{
|
||||
if (flashing == flashApplied) return;
|
||||
flashApplied = flashing;
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
renderers[i].sharedMaterials = flashing ? flashMaterials[i] : originalMaterials[i];
|
||||
}
|
||||
|
||||
// Returns true if this hit killed the target. hitDirection is the bullet's travel direction.
|
||||
public bool TakeDamage(float damage, Vector3 hitDirection, bool headshot)
|
||||
{
|
||||
if (IsDead) return false;
|
||||
|
||||
Health = Mathf.Max(0f, Health - damage);
|
||||
flashUntil = Time.time + 0.08f;
|
||||
|
||||
// Tilt away from the shot: rotate around the horizontal axis perpendicular to it.
|
||||
Vector3 flat = Vector3.ProjectOnPlane(hitDirection, Vector3.up);
|
||||
if (flat.sqrMagnitude > 0.0001f)
|
||||
{
|
||||
flinchAxis = Vector3.Cross(Vector3.up, flat.normalized);
|
||||
deathAxis = flinchAxis;
|
||||
}
|
||||
flinchVelocity += headshot ? 260f : 140f;
|
||||
|
||||
if (Health <= 0f)
|
||||
{
|
||||
IsDead = true;
|
||||
diedAt = Time.time;
|
||||
respawnAt = Time.time + respawnDelay;
|
||||
foreach (var c in colliders) c.enabled = false;
|
||||
healthLabel.gameObject.SetActive(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Intro cinematic: the bot starts fully hidden, then PopIn() beams it in.
|
||||
public void HideForIntro()
|
||||
{
|
||||
hiddenForIntro = true;
|
||||
visual.localScale = Vector3.one * 0.0001f;
|
||||
plateRenderer.enabled = false;
|
||||
healthLabel.gameObject.SetActive(false);
|
||||
foreach (var c in colliders) c.enabled = false;
|
||||
}
|
||||
|
||||
public void PopIn()
|
||||
{
|
||||
if (!hiddenForIntro) return;
|
||||
hiddenForIntro = false;
|
||||
plateRenderer.enabled = true;
|
||||
Respawn();
|
||||
ShootingFx.SpawnBeam(transform.position);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (hiddenForIntro) return;
|
||||
|
||||
if (IsDead)
|
||||
{
|
||||
AnimateDeath();
|
||||
if (Time.time >= respawnAt) Respawn();
|
||||
return;
|
||||
}
|
||||
|
||||
if (strafeDistance > 0f)
|
||||
{
|
||||
float offset = Mathf.PingPong(Time.time * strafeSpeed, strafeDistance * 2f) - strafeDistance;
|
||||
transform.localPosition = startPosition + Vector3.right * offset;
|
||||
}
|
||||
|
||||
// Damped spring back to upright.
|
||||
const float stiffness = 180f;
|
||||
const float damping = 14f;
|
||||
flinchVelocity += (-stiffness * flinchAngle - damping * flinchVelocity) * Time.deltaTime;
|
||||
flinchAngle += flinchVelocity * Time.deltaTime;
|
||||
|
||||
float pop = Mathf.Clamp01((Time.time - respawnedAt) / RespawnPopTime);
|
||||
float scale = EaseOutBack(pop);
|
||||
visual.localScale = new Vector3(1f, Mathf.Max(0.01f, scale), 1f);
|
||||
visual.rotation = Quaternion.AngleAxis(flinchAngle, flinchAxis);
|
||||
|
||||
bool flashing = Time.time < flashUntil;
|
||||
ApplyFlash(flashing);
|
||||
|
||||
healthLabel.text = Mathf.CeilToInt(Health).ToString();
|
||||
healthLabel.color = Color.Lerp(Color.red, Color.green, Health / maxHealth);
|
||||
|
||||
var cam = Camera.main;
|
||||
if (cam != null)
|
||||
healthLabel.transform.rotation = cam.transform.rotation;
|
||||
}
|
||||
|
||||
// Topple over backwards (away from the shooter), then sink into the floor.
|
||||
void AnimateDeath()
|
||||
{
|
||||
float t = Time.time - diedAt;
|
||||
float fall = Mathf.Clamp01(t / DeathFallTime);
|
||||
float angle = Mathf.Lerp(0f, 90f, fall * fall); // accelerate like it's falling
|
||||
visual.rotation = Quaternion.AngleAxis(angle, deathAxis);
|
||||
|
||||
float sink = Mathf.Clamp01((t - DeathFallTime - 0.6f) / 0.5f);
|
||||
visual.localPosition = Vector3.down * sink * 0.8f;
|
||||
|
||||
bool flashing = Time.time < flashUntil;
|
||||
ApplyFlash(flashing);
|
||||
}
|
||||
|
||||
void Respawn()
|
||||
{
|
||||
Health = maxHealth;
|
||||
IsDead = false;
|
||||
respawnedAt = Time.time;
|
||||
flinchAngle = 0f;
|
||||
flinchVelocity = 0f;
|
||||
visual.localPosition = Vector3.zero;
|
||||
visual.rotation = Quaternion.identity;
|
||||
foreach (var c in colliders) c.enabled = true;
|
||||
healthLabel.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
static float EaseOutBack(float x)
|
||||
{
|
||||
const float c1 = 1.70158f;
|
||||
const float c3 = c1 + 1f;
|
||||
return 1f + c3 * Mathf.Pow(x - 1f, 3f) + c1 * Mathf.Pow(x - 1f, 2f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad667a6d0d543b04ab5081034353bdb1
|
||||
@@ -0,0 +1,14 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Marks a target collider as head or body so the shooter can apply the right damage.
|
||||
public class ShootingTargetHitbox : MonoBehaviour
|
||||
{
|
||||
public ShootingTarget Owner { get; private set; }
|
||||
public bool IsHead { get; private set; }
|
||||
|
||||
public void Init(ShootingTarget owner, bool isHead)
|
||||
{
|
||||
Owner = owner;
|
||||
IsHead = isHead;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 354c051470a8bbd4e8f3ac1d45866f8f
|
||||
@@ -0,0 +1,264 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Rifle held by the local player's humanoid (Y Bot): the AK47 model from Resources (falls back
|
||||
// to a primitive stand-in if it's missing), posed in
|
||||
// front of the right shoulder and pointed at whatever the crosshair is over, with both hands
|
||||
// pinned to it via Animator IK (needs "IK Pass" on the animator layer). Also owns the
|
||||
// procedural weapon animations: fire kick, muzzle flash, and the reload (mag drop / insert).
|
||||
// Runs after CharacterMovement so it reads the camera pose for this frame.
|
||||
[DefaultExecutionOrder(100)]
|
||||
[RequireComponent(typeof(Animator))]
|
||||
public class WeaponRig : MonoBehaviour
|
||||
{
|
||||
const string ModelPath = "Weapons/AK47";
|
||||
|
||||
public Vector3 shoulderOffset = new Vector3(0.18f, 1.45f, 0f);
|
||||
// Where the pistol grip (the gun's pivot) sits relative to the shoulder, in aim space.
|
||||
// Kept close so the Y Bot's left arm can reach the handguard.
|
||||
public Vector3 holdOffset = new Vector3(-0.02f, -0.12f, 0.24f);
|
||||
|
||||
public Transform Muzzle { get; private set; }
|
||||
|
||||
Animator animator;
|
||||
Transform gun;
|
||||
Transform rightGrip;
|
||||
Transform leftGrip;
|
||||
Transform magazine;
|
||||
Vector3 magazineRestPos;
|
||||
Transform flash;
|
||||
Light flashLight;
|
||||
|
||||
Vector3 aimPoint;
|
||||
float kickBack;
|
||||
float kickPitch;
|
||||
float flashUntil;
|
||||
|
||||
float equipProgress = 1f;
|
||||
bool equipping;
|
||||
float equipStart;
|
||||
float equipDuration;
|
||||
|
||||
bool reloading;
|
||||
float reloadStart;
|
||||
float reloadDuration;
|
||||
bool magDropped;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
animator = GetComponent<Animator>();
|
||||
BuildGun();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (gun != null) Destroy(gun.gameObject);
|
||||
}
|
||||
|
||||
// Gun root pivot is the pistol grip (right hand); forward +Z, up +Y.
|
||||
void BuildGun()
|
||||
{
|
||||
gun = new GameObject("Rifle").transform;
|
||||
gun.SetParent(transform, false);
|
||||
|
||||
// Real model (Quaternius CC0 AK47, see Resources/Weapons/AK47_LICENSE.txt), else a primitive stand-in.
|
||||
var model = Resources.Load<GameObject>(ModelPath);
|
||||
if (model != null)
|
||||
{
|
||||
var instance = Instantiate(model, gun, false).transform;
|
||||
instance.name = model.name;
|
||||
foreach (var c in instance.GetComponentsInChildren<Collider>()) Destroy(c);
|
||||
Muzzle = instance.Find("Muzzle");
|
||||
rightGrip = instance.Find("RightHandGrip");
|
||||
leftGrip = instance.Find("LeftHandGrip");
|
||||
magazine = instance.Find("Magazine");
|
||||
}
|
||||
|
||||
if (Muzzle == null || rightGrip == null || leftGrip == null || magazine == null)
|
||||
{
|
||||
if (model != null) Debug.LogWarning($"[WeaponRig] '{ModelPath}' is missing Muzzle/RightHandGrip/LeftHandGrip/Magazine; using the primitive rifle.");
|
||||
foreach (Transform child in gun) Destroy(child.gameObject);
|
||||
BuildPrimitiveGun();
|
||||
}
|
||||
|
||||
magazineRestPos = magazine.localPosition;
|
||||
|
||||
flash = ShootingFx.CreateVisual(PrimitiveType.Sphere, "MuzzleFlash", Muzzle, new Vector3(0f, 0f, 0.06f),
|
||||
new Vector3(0.09f, 0.09f, 0.2f), ShootingFx.GetMaterial(new Color(1f, 0.7f, 0.25f), 6f)).transform;
|
||||
flash.gameObject.SetActive(false);
|
||||
|
||||
flashLight = new GameObject("MuzzleLight").AddComponent<Light>();
|
||||
flashLight.transform.SetParent(Muzzle, false);
|
||||
flashLight.type = LightType.Point;
|
||||
flashLight.color = new Color(1f, 0.75f, 0.4f);
|
||||
flashLight.range = 4f;
|
||||
flashLight.intensity = 1.5f;
|
||||
flashLight.enabled = false;
|
||||
}
|
||||
|
||||
void BuildPrimitiveGun()
|
||||
{
|
||||
var dark = ShootingFx.GetMaterial(new Color(0.12f, 0.12f, 0.14f));
|
||||
var metal = ShootingFx.GetMaterial(new Color(0.32f, 0.33f, 0.36f));
|
||||
var accent = ShootingFx.GetMaterial(new Color(0.95f, 0.3f, 0.25f));
|
||||
|
||||
// Parts are laid out around the receiver; offset them so the grip sits at the root pivot.
|
||||
var body = new GameObject("PrimitiveRifle").transform;
|
||||
body.SetParent(gun, false);
|
||||
body.localPosition = new Vector3(-0.01f, 0.09f, 0.05f);
|
||||
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Receiver", body, new Vector3(0f, 0f, 0.05f), new Vector3(0.07f, 0.1f, 0.42f), dark);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Handguard", body, new Vector3(0f, -0.005f, 0.33f), new Vector3(0.075f, 0.085f, 0.2f), metal);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cylinder, "Barrel", body, new Vector3(0f, 0.01f, 0.5f), new Vector3(0.025f, 0.13f, 0.025f), dark, new Vector3(90f, 0f, 0f));
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Stock", body, new Vector3(0f, -0.025f, -0.26f), new Vector3(0.055f, 0.11f, 0.22f), metal);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Grip", body, new Vector3(0f, -0.1f, -0.06f), new Vector3(0.045f, 0.12f, 0.05f), dark, new Vector3(15f, 0f, 0f));
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Sight", body, new Vector3(0f, 0.07f, 0.04f), new Vector3(0.03f, 0.04f, 0.09f), dark);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "SightDot", body, new Vector3(0f, 0.08f, 0.09f), Vector3.one * 0.012f, ShootingFx.GetMaterial(Color.red, 3f));
|
||||
magazine = ShootingFx.CreateVisual(PrimitiveType.Cube, "Magazine", body, new Vector3(0f, -0.12f, 0.12f), new Vector3(0.045f, 0.16f, 0.08f), accent, new Vector3(-12f, 0f, 0f)).transform;
|
||||
|
||||
Muzzle = new GameObject("Muzzle").transform;
|
||||
Muzzle.SetParent(body, false);
|
||||
Muzzle.localPosition = new Vector3(0f, 0.01f, 0.64f);
|
||||
|
||||
rightGrip = new GameObject("RightHandGrip").transform;
|
||||
rightGrip.SetParent(body, false);
|
||||
rightGrip.localPosition = new Vector3(0.01f, -0.09f, -0.05f);
|
||||
|
||||
leftGrip = new GameObject("LeftHandGrip").transform;
|
||||
leftGrip.SetParent(body, false);
|
||||
leftGrip.localPosition = new Vector3(-0.01f, -0.06f, 0.32f);
|
||||
}
|
||||
|
||||
public void SetAimPoint(Vector3 point) => aimPoint = point;
|
||||
|
||||
public void PlayFire()
|
||||
{
|
||||
kickBack = Mathf.Min(kickBack + 0.05f, 0.09f);
|
||||
kickPitch = Mathf.Min(kickPitch + 5f, 12f);
|
||||
flashUntil = Time.time + 0.04f;
|
||||
flash.localRotation = Quaternion.Euler(0f, 0f, Random.Range(0f, 360f));
|
||||
flash.localScale = new Vector3(0.09f, 0.09f, 0.2f) * Random.Range(0.8f, 1.25f);
|
||||
}
|
||||
|
||||
// Holds the rifle at low-ready until PlayEquip() raises it (used by the intro cinematic).
|
||||
public void SetLowered()
|
||||
{
|
||||
equipping = false;
|
||||
equipProgress = 0f;
|
||||
}
|
||||
|
||||
public void PlayEquip(float duration)
|
||||
{
|
||||
equipping = true;
|
||||
equipStart = Time.time;
|
||||
equipDuration = Mathf.Max(0.01f, duration);
|
||||
}
|
||||
|
||||
static float EaseOutBack(float x)
|
||||
{
|
||||
const float c1 = 1.70158f;
|
||||
const float c3 = c1 + 1f;
|
||||
return 1f + c3 * Mathf.Pow(x - 1f, 3f) + c1 * Mathf.Pow(x - 1f, 2f);
|
||||
}
|
||||
|
||||
public void PlayReload(float duration)
|
||||
{
|
||||
reloading = true;
|
||||
reloadStart = Time.time;
|
||||
reloadDuration = duration;
|
||||
magDropped = false;
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
float dt = Time.deltaTime;
|
||||
kickBack = Mathf.Lerp(kickBack, 0f, 1f - Mathf.Exp(-18f * dt));
|
||||
kickPitch = Mathf.Lerp(kickPitch, 0f, 1f - Mathf.Exp(-14f * dt));
|
||||
|
||||
bool flashing = Time.time < flashUntil;
|
||||
flash.gameObject.SetActive(flashing);
|
||||
flashLight.enabled = flashing;
|
||||
|
||||
// Pose: in front of the right shoulder, pointed at the crosshair target.
|
||||
Vector3 shoulder = transform.TransformPoint(shoulderOffset);
|
||||
Vector3 toAim = aimPoint - shoulder;
|
||||
Quaternion aim = toAim.sqrMagnitude > 0.5f ? Quaternion.LookRotation(toAim) : transform.rotation;
|
||||
|
||||
float reloadTilt = 0f;
|
||||
if (reloading) reloadTilt = UpdateReload();
|
||||
|
||||
// Equip: 1 = fully lowered (muzzle down at the hip), 0 = raised. Overshoots slightly on the way up.
|
||||
if (equipping)
|
||||
{
|
||||
equipProgress = Mathf.Clamp01((Time.time - equipStart) / equipDuration);
|
||||
if (equipProgress >= 1f) equipping = false;
|
||||
}
|
||||
float lowered = 1f - EaseOutBack(equipProgress);
|
||||
|
||||
Quaternion rotation = aim
|
||||
* Quaternion.Euler(-kickPitch + reloadTilt * 25f + lowered * 55f, lowered * -15f, reloadTilt * -40f + lowered * -20f);
|
||||
Vector3 position = shoulder + aim * (holdOffset + Vector3.back * (kickBack + lowered * 0.08f)
|
||||
+ Vector3.down * (reloadTilt * 0.08f + lowered * 0.25f));
|
||||
gun.SetPositionAndRotation(position, rotation);
|
||||
}
|
||||
|
||||
// Returns 0..1 "how far the gun is tilted for the reload" and drives the magazine.
|
||||
float UpdateReload()
|
||||
{
|
||||
float t = (Time.time - reloadStart) / reloadDuration;
|
||||
if (t >= 1f)
|
||||
{
|
||||
reloading = false;
|
||||
magazine.localPosition = magazineRestPos;
|
||||
magazine.gameObject.SetActive(true);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
// Tilt in over the first 15%, hold, tilt back out over the last 15%.
|
||||
float tilt = Mathf.SmoothStep(0f, 1f, Mathf.Min(t / 0.15f, (1f - t) / 0.15f, 1f));
|
||||
|
||||
if (t >= 0.25f && !magDropped)
|
||||
{
|
||||
magDropped = true;
|
||||
DropMagazineCopy();
|
||||
magazine.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (t >= 0.55f)
|
||||
{
|
||||
// New mag slides up into the well.
|
||||
magazine.gameObject.SetActive(true);
|
||||
float insert = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(0.55f, 0.8f, t));
|
||||
magazine.localPosition = magazineRestPos + Vector3.down * (0.25f * (1f - insert));
|
||||
}
|
||||
|
||||
return tilt;
|
||||
}
|
||||
|
||||
void DropMagazineCopy()
|
||||
{
|
||||
var dropped = Instantiate(magazine.gameObject, magazine.position, magazine.rotation);
|
||||
dropped.name = "DroppedMagazine";
|
||||
dropped.transform.localScale = magazine.lossyScale;
|
||||
dropped.AddComponent<BoxCollider>();
|
||||
var body = dropped.AddComponent<Rigidbody>();
|
||||
body.linearVelocity = -gun.up * 1.5f;
|
||||
body.angularVelocity = Random.insideUnitSphere * 5f;
|
||||
Destroy(dropped, 4f);
|
||||
}
|
||||
|
||||
void OnAnimatorIK(int layerIndex)
|
||||
{
|
||||
if (gun == null) return;
|
||||
|
||||
animator.SetIKPositionWeight(AvatarIKGoal.RightHand, 1f);
|
||||
animator.SetIKPosition(AvatarIKGoal.RightHand, rightGrip.position);
|
||||
|
||||
// During the reload the left hand goes to the magazine well instead of the handguard.
|
||||
Vector3 leftTarget = reloading ? magazine.position : leftGrip.position;
|
||||
animator.SetIKPositionWeight(AvatarIKGoal.LeftHand, 1f);
|
||||
animator.SetIKPosition(AvatarIKGoal.LeftHand, leftTarget);
|
||||
|
||||
animator.SetLookAtWeight(1f, 0.35f, 0.8f, 0f, 0.6f);
|
||||
animator.SetLookAtPosition(aimPoint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c44dd493661648469b3030de85535d9
|
||||
Reference in New Issue
Block a user