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
+26
View File
@@ -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);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7f748ccac31f7bf4f8fccf40e16d32ad
+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;
}
}
@@ -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
+180
View File
@@ -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
+63
View File
@@ -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
+276
View File
@@ -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
+264
View File
@@ -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