- 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>
374 lines
15 KiB
C#
374 lines
15 KiB
C#
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);
|
||
}
|
||
}
|