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 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 rangeTargets) { targets = rangeTargets; if (sfx == null) { sfx = gameObject.AddComponent(); sfx.playOnAwake = false; blipSource = gameObject.AddComponent(); 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(); if (rig == null) rig = FindAnyObjectByType(); 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(); 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); } }