Files
Test-Multiplayer/Assets/Scripts/Shooting/WeaponRig.cs
T
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

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);
}
}