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
@@ -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);
}
}