- 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>
265 lines
10 KiB
C#
265 lines
10 KiB
C#
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);
|
|
}
|
|
}
|