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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user