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,264 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Rifle held by the local player's humanoid (Y Bot): the AK47 model from Resources (falls back
|
||||
// to a primitive stand-in if it's missing), posed in
|
||||
// front of the right shoulder and pointed at whatever the crosshair is over, with both hands
|
||||
// pinned to it via Animator IK (needs "IK Pass" on the animator layer). Also owns the
|
||||
// procedural weapon animations: fire kick, muzzle flash, and the reload (mag drop / insert).
|
||||
// Runs after CharacterMovement so it reads the camera pose for this frame.
|
||||
[DefaultExecutionOrder(100)]
|
||||
[RequireComponent(typeof(Animator))]
|
||||
public class WeaponRig : MonoBehaviour
|
||||
{
|
||||
const string ModelPath = "Weapons/AK47";
|
||||
|
||||
public Vector3 shoulderOffset = new Vector3(0.18f, 1.45f, 0f);
|
||||
// Where the pistol grip (the gun's pivot) sits relative to the shoulder, in aim space.
|
||||
// Kept close so the Y Bot's left arm can reach the handguard.
|
||||
public Vector3 holdOffset = new Vector3(-0.02f, -0.12f, 0.24f);
|
||||
|
||||
public Transform Muzzle { get; private set; }
|
||||
|
||||
Animator animator;
|
||||
Transform gun;
|
||||
Transform rightGrip;
|
||||
Transform leftGrip;
|
||||
Transform magazine;
|
||||
Vector3 magazineRestPos;
|
||||
Transform flash;
|
||||
Light flashLight;
|
||||
|
||||
Vector3 aimPoint;
|
||||
float kickBack;
|
||||
float kickPitch;
|
||||
float flashUntil;
|
||||
|
||||
float equipProgress = 1f;
|
||||
bool equipping;
|
||||
float equipStart;
|
||||
float equipDuration;
|
||||
|
||||
bool reloading;
|
||||
float reloadStart;
|
||||
float reloadDuration;
|
||||
bool magDropped;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
animator = GetComponent<Animator>();
|
||||
BuildGun();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (gun != null) Destroy(gun.gameObject);
|
||||
}
|
||||
|
||||
// Gun root pivot is the pistol grip (right hand); forward +Z, up +Y.
|
||||
void BuildGun()
|
||||
{
|
||||
gun = new GameObject("Rifle").transform;
|
||||
gun.SetParent(transform, false);
|
||||
|
||||
// Real model (Quaternius CC0 AK47, see Resources/Weapons/AK47_LICENSE.txt), else a primitive stand-in.
|
||||
var model = Resources.Load<GameObject>(ModelPath);
|
||||
if (model != null)
|
||||
{
|
||||
var instance = Instantiate(model, gun, false).transform;
|
||||
instance.name = model.name;
|
||||
foreach (var c in instance.GetComponentsInChildren<Collider>()) Destroy(c);
|
||||
Muzzle = instance.Find("Muzzle");
|
||||
rightGrip = instance.Find("RightHandGrip");
|
||||
leftGrip = instance.Find("LeftHandGrip");
|
||||
magazine = instance.Find("Magazine");
|
||||
}
|
||||
|
||||
if (Muzzle == null || rightGrip == null || leftGrip == null || magazine == null)
|
||||
{
|
||||
if (model != null) Debug.LogWarning($"[WeaponRig] '{ModelPath}' is missing Muzzle/RightHandGrip/LeftHandGrip/Magazine; using the primitive rifle.");
|
||||
foreach (Transform child in gun) Destroy(child.gameObject);
|
||||
BuildPrimitiveGun();
|
||||
}
|
||||
|
||||
magazineRestPos = magazine.localPosition;
|
||||
|
||||
flash = ShootingFx.CreateVisual(PrimitiveType.Sphere, "MuzzleFlash", Muzzle, new Vector3(0f, 0f, 0.06f),
|
||||
new Vector3(0.09f, 0.09f, 0.2f), ShootingFx.GetMaterial(new Color(1f, 0.7f, 0.25f), 6f)).transform;
|
||||
flash.gameObject.SetActive(false);
|
||||
|
||||
flashLight = new GameObject("MuzzleLight").AddComponent<Light>();
|
||||
flashLight.transform.SetParent(Muzzle, false);
|
||||
flashLight.type = LightType.Point;
|
||||
flashLight.color = new Color(1f, 0.75f, 0.4f);
|
||||
flashLight.range = 4f;
|
||||
flashLight.intensity = 1.5f;
|
||||
flashLight.enabled = false;
|
||||
}
|
||||
|
||||
void BuildPrimitiveGun()
|
||||
{
|
||||
var dark = ShootingFx.GetMaterial(new Color(0.12f, 0.12f, 0.14f));
|
||||
var metal = ShootingFx.GetMaterial(new Color(0.32f, 0.33f, 0.36f));
|
||||
var accent = ShootingFx.GetMaterial(new Color(0.95f, 0.3f, 0.25f));
|
||||
|
||||
// Parts are laid out around the receiver; offset them so the grip sits at the root pivot.
|
||||
var body = new GameObject("PrimitiveRifle").transform;
|
||||
body.SetParent(gun, false);
|
||||
body.localPosition = new Vector3(-0.01f, 0.09f, 0.05f);
|
||||
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Receiver", body, new Vector3(0f, 0f, 0.05f), new Vector3(0.07f, 0.1f, 0.42f), dark);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Handguard", body, new Vector3(0f, -0.005f, 0.33f), new Vector3(0.075f, 0.085f, 0.2f), metal);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cylinder, "Barrel", body, new Vector3(0f, 0.01f, 0.5f), new Vector3(0.025f, 0.13f, 0.025f), dark, new Vector3(90f, 0f, 0f));
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Stock", body, new Vector3(0f, -0.025f, -0.26f), new Vector3(0.055f, 0.11f, 0.22f), metal);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Grip", body, new Vector3(0f, -0.1f, -0.06f), new Vector3(0.045f, 0.12f, 0.05f), dark, new Vector3(15f, 0f, 0f));
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "Sight", body, new Vector3(0f, 0.07f, 0.04f), new Vector3(0.03f, 0.04f, 0.09f), dark);
|
||||
ShootingFx.CreateVisual(PrimitiveType.Cube, "SightDot", body, new Vector3(0f, 0.08f, 0.09f), Vector3.one * 0.012f, ShootingFx.GetMaterial(Color.red, 3f));
|
||||
magazine = ShootingFx.CreateVisual(PrimitiveType.Cube, "Magazine", body, new Vector3(0f, -0.12f, 0.12f), new Vector3(0.045f, 0.16f, 0.08f), accent, new Vector3(-12f, 0f, 0f)).transform;
|
||||
|
||||
Muzzle = new GameObject("Muzzle").transform;
|
||||
Muzzle.SetParent(body, false);
|
||||
Muzzle.localPosition = new Vector3(0f, 0.01f, 0.64f);
|
||||
|
||||
rightGrip = new GameObject("RightHandGrip").transform;
|
||||
rightGrip.SetParent(body, false);
|
||||
rightGrip.localPosition = new Vector3(0.01f, -0.09f, -0.05f);
|
||||
|
||||
leftGrip = new GameObject("LeftHandGrip").transform;
|
||||
leftGrip.SetParent(body, false);
|
||||
leftGrip.localPosition = new Vector3(-0.01f, -0.06f, 0.32f);
|
||||
}
|
||||
|
||||
public void SetAimPoint(Vector3 point) => aimPoint = point;
|
||||
|
||||
public void PlayFire()
|
||||
{
|
||||
kickBack = Mathf.Min(kickBack + 0.05f, 0.09f);
|
||||
kickPitch = Mathf.Min(kickPitch + 5f, 12f);
|
||||
flashUntil = Time.time + 0.04f;
|
||||
flash.localRotation = Quaternion.Euler(0f, 0f, Random.Range(0f, 360f));
|
||||
flash.localScale = new Vector3(0.09f, 0.09f, 0.2f) * Random.Range(0.8f, 1.25f);
|
||||
}
|
||||
|
||||
// Holds the rifle at low-ready until PlayEquip() raises it (used by the intro cinematic).
|
||||
public void SetLowered()
|
||||
{
|
||||
equipping = false;
|
||||
equipProgress = 0f;
|
||||
}
|
||||
|
||||
public void PlayEquip(float duration)
|
||||
{
|
||||
equipping = true;
|
||||
equipStart = Time.time;
|
||||
equipDuration = Mathf.Max(0.01f, duration);
|
||||
}
|
||||
|
||||
static float EaseOutBack(float x)
|
||||
{
|
||||
const float c1 = 1.70158f;
|
||||
const float c3 = c1 + 1f;
|
||||
return 1f + c3 * Mathf.Pow(x - 1f, 3f) + c1 * Mathf.Pow(x - 1f, 2f);
|
||||
}
|
||||
|
||||
public void PlayReload(float duration)
|
||||
{
|
||||
reloading = true;
|
||||
reloadStart = Time.time;
|
||||
reloadDuration = duration;
|
||||
magDropped = false;
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
float dt = Time.deltaTime;
|
||||
kickBack = Mathf.Lerp(kickBack, 0f, 1f - Mathf.Exp(-18f * dt));
|
||||
kickPitch = Mathf.Lerp(kickPitch, 0f, 1f - Mathf.Exp(-14f * dt));
|
||||
|
||||
bool flashing = Time.time < flashUntil;
|
||||
flash.gameObject.SetActive(flashing);
|
||||
flashLight.enabled = flashing;
|
||||
|
||||
// Pose: in front of the right shoulder, pointed at the crosshair target.
|
||||
Vector3 shoulder = transform.TransformPoint(shoulderOffset);
|
||||
Vector3 toAim = aimPoint - shoulder;
|
||||
Quaternion aim = toAim.sqrMagnitude > 0.5f ? Quaternion.LookRotation(toAim) : transform.rotation;
|
||||
|
||||
float reloadTilt = 0f;
|
||||
if (reloading) reloadTilt = UpdateReload();
|
||||
|
||||
// Equip: 1 = fully lowered (muzzle down at the hip), 0 = raised. Overshoots slightly on the way up.
|
||||
if (equipping)
|
||||
{
|
||||
equipProgress = Mathf.Clamp01((Time.time - equipStart) / equipDuration);
|
||||
if (equipProgress >= 1f) equipping = false;
|
||||
}
|
||||
float lowered = 1f - EaseOutBack(equipProgress);
|
||||
|
||||
Quaternion rotation = aim
|
||||
* Quaternion.Euler(-kickPitch + reloadTilt * 25f + lowered * 55f, lowered * -15f, reloadTilt * -40f + lowered * -20f);
|
||||
Vector3 position = shoulder + aim * (holdOffset + Vector3.back * (kickBack + lowered * 0.08f)
|
||||
+ Vector3.down * (reloadTilt * 0.08f + lowered * 0.25f));
|
||||
gun.SetPositionAndRotation(position, rotation);
|
||||
}
|
||||
|
||||
// Returns 0..1 "how far the gun is tilted for the reload" and drives the magazine.
|
||||
float UpdateReload()
|
||||
{
|
||||
float t = (Time.time - reloadStart) / reloadDuration;
|
||||
if (t >= 1f)
|
||||
{
|
||||
reloading = false;
|
||||
magazine.localPosition = magazineRestPos;
|
||||
magazine.gameObject.SetActive(true);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
// Tilt in over the first 15%, hold, tilt back out over the last 15%.
|
||||
float tilt = Mathf.SmoothStep(0f, 1f, Mathf.Min(t / 0.15f, (1f - t) / 0.15f, 1f));
|
||||
|
||||
if (t >= 0.25f && !magDropped)
|
||||
{
|
||||
magDropped = true;
|
||||
DropMagazineCopy();
|
||||
magazine.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (t >= 0.55f)
|
||||
{
|
||||
// New mag slides up into the well.
|
||||
magazine.gameObject.SetActive(true);
|
||||
float insert = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(0.55f, 0.8f, t));
|
||||
magazine.localPosition = magazineRestPos + Vector3.down * (0.25f * (1f - insert));
|
||||
}
|
||||
|
||||
return tilt;
|
||||
}
|
||||
|
||||
void DropMagazineCopy()
|
||||
{
|
||||
var dropped = Instantiate(magazine.gameObject, magazine.position, magazine.rotation);
|
||||
dropped.name = "DroppedMagazine";
|
||||
dropped.transform.localScale = magazine.lossyScale;
|
||||
dropped.AddComponent<BoxCollider>();
|
||||
var body = dropped.AddComponent<Rigidbody>();
|
||||
body.linearVelocity = -gun.up * 1.5f;
|
||||
body.angularVelocity = Random.insideUnitSphere * 5f;
|
||||
Destroy(dropped, 4f);
|
||||
}
|
||||
|
||||
void OnAnimatorIK(int layerIndex)
|
||||
{
|
||||
if (gun == null) return;
|
||||
|
||||
animator.SetIKPositionWeight(AvatarIKGoal.RightHand, 1f);
|
||||
animator.SetIKPosition(AvatarIKGoal.RightHand, rightGrip.position);
|
||||
|
||||
// During the reload the left hand goes to the magazine well instead of the handguard.
|
||||
Vector3 leftTarget = reloading ? magazine.position : leftGrip.position;
|
||||
animator.SetIKPositionWeight(AvatarIKGoal.LeftHand, 1f);
|
||||
animator.SetIKPosition(AvatarIKGoal.LeftHand, leftTarget);
|
||||
|
||||
animator.SetLookAtWeight(1f, 0.35f, 0.8f, 0f, 0.6f);
|
||||
animator.SetLookAtPosition(aimPoint);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user