Files
pelpanagiotisandClaude Opus 5.5 5fc9ebf1e6 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>
2026-09-25 08:56:35 +03:00

374 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}