swb_base/structures/ShootInfo.cs

Component that defines weapon shooting properties and behaviour data. It holds ammo, bullet, recoil, damage, penetration, visual/sound/particle settings, and provides DamageFor(distance, hitTags) to compute per-bullet damage with falloff and hit-group multipliers.

File Access
using SWB.Shared;

namespace SWB.Base;

public enum FiringType
{
	/// <summary>Single fire</summary>
	semi,
	/// <summary>Automatic fire</summary>
	auto,
	/// <summary>3-Burst fire</summary>
	burst
}

public enum InfiniteAmmoType
{
	/// <summary>No infinite ammo</summary>
	disabled = 0,
	/// <summary>Infinite clip ammo, no need to reload</summary>
	clip = 1,
	/// <summary>Infinite reserve ammo, can always reload</summary>
	reserve = 2
}

[Group( "SWB" )]
[Title( "ShootInfo" )]
public class ShootInfo : Component
{
	/// <summary>Bullet type (Hitscan/Physical)</summary>
	[Property, Group( "Bullets" )] public BulletInfo BulletType { get; set; }

	/// <summary>Type of ammo</summary>
	[Property, Group( "Ammo" )] public string AmmoType { get; set; } = "pistol";

	/// <summary>Amount of ammo in the clip</summary>
	[Property, Group( "Ammo" ), Sync] public int Ammo { get; set; } = 10;

	/// <summary>Size of the clip</summary>
	[Property, Group( "Ammo" )] public int ClipSize { get; set; } = 10;

	/// <summary>If the weapon should have infinite ammo</summary>
	[Property, Group( "Ammo" )] public InfiniteAmmoType InfiniteAmmo { get; set; } = InfiniteAmmoType.disabled;

	// Shooting //

	/// <summary>Amount of bullets per shot</summary>
	[Property, Group( "Bullets" )] public int Bullets { get; set; } = 1;

	/// <summary>Bullet size</summary>
	[Property, Group( "Bullets" )] public float BulletSize { get; set; } = 0.1f;

	/// <summary>Chance the BulletTracerParticle is created (0-1)</summary>
	[Property, Group( "Bullets" )] public float BulletTracerChance { get; set; } = 0.33f;

	/// <summary>Damage per bullet</summary>  
	[Property, Group( "Bullets" )] public float Damage { get; set; } = 5;

	// ── ARC9 parity: ammo ────────────────────────────────────────────────────

	/// <summary>Rounds consumed per trigger pull. Shotguns/burst weapons use >1.</summary>
	[Property, Group( "Ammo" )] public int AmmoPerShot { get; set; } = 1;

	/// <summary>
	/// Rounds held in the chamber ON TOP of the magazine, so a topped-up reload
	/// gives ClipSize + ChamberSize.
	///
	/// ⚠️ SWB already had `BulletCocking`, a BOOL meaning "+1". This supersedes it
	/// with a count, because ARC9 authors it as a number and some weapons chamber
	/// more than one. BulletCocking is honoured as ChamberSize=1 when this is 0,
	/// so nothing that relied on it changes.
	/// </summary>
	[Property, Group( "Ammo" )] public int ChamberSize { get; set; } = 0;

	// ── ARC9 parity: accuracy ────────────────────────────────────────────────
	//
	// ⚠️ ADDITIVE terms are added to the spread ANGLE; MULT terms scale the total.
	// ARC9 keeps them separate and so does this — collapsing them into one number
	// makes hipfire penalties scale with sights bonuses, which is not the same
	// curve and feels wrong at the extremes.
	//
	// ⚠️ Defaults are no-ops (add 0, mult 1): existing weapons are untouched.

	[Property, Group( "Accuracy" )] public float SpreadAddHipFire { get; set; } = 0f;
	[Property, Group( "Accuracy" )] public float SpreadAddMidAir { get; set; } = 0f;
	[Property, Group( "Accuracy" )] public float SpreadAddMove { get; set; } = 0f;
	[Property, Group( "Accuracy" )] public float SpreadMultSights { get; set; } = 1f;
	[Property, Group( "Accuracy" )] public float SpreadMultShooting { get; set; } = 1f;
	[Property, Group( "Accuracy" )] public float SpreadMultMoveSights { get; set; } = 1f;

	// ── ARC9 parity: recoil ──────────────────────────────────────────────────
	//
	// ⛔ SWB HAD ONE `Recoil` FLOAT. ARC9 models recoil as an ACCUMULATOR with a
	// learnable pattern: each shot pushes up/side, randomness is separate from the
	// pattern so the pattern stays learnable, the accumulator decays between
	// shots, and it snaps back after a pause. `Recoil` is kept as the base
	// magnitude so existing weapons still behave if these are left at default.

	// ── base x multiplier ────────────────────────────────────────────────────
	// ⚠️ EVERY WEAPON STARTS AT 1x AND THAT IS FREE, not a migration. These are NEW properties,
	// so they are absent from all 496 prefabs, so deserialisation leaves the initialiser — the
	// whole fleet is 1x without touching a single asset.
	//
	// ⛔ THEY ONLY DO ANYTHING WHILE `GlobalHandling.UseRecoilBase` IS ON. With it off the
	// authored `RecoilUp` / `RecoilSide` below are used exactly as before, so this cannot
	// silently change the fleet before anyone has chosen a base.

	/// <summary>This weapon's share of the global vertical base. 1 = the base itself.</summary>
	[Property, Group( "Recoil" )] public float RecoilVerticalMult { get; set; } = 1f;

	/// <summary>This weapon's share of the global horizontal base.</summary>
	[Property, Group( "Recoil" )] public float RecoilHorizontalMult { get; set; } = 1f;

	/// <summary>Upward kick per shot, in degrees.</summary>
	[Property, Group( "Recoil" )] public float RecoilUp { get; set; } = 0f;

	/// <summary>Sideways drift per shot. Positive is right.</summary>
	[Property, Group( "Recoil" )] public float RecoilSide { get; set; } = 0f;

	/// <summary>Random vertical added on top of the pattern.</summary>
	[Property, Group( "Recoil" )] public float RecoilRandomUp { get; set; } = 0f;

	/// <summary>Random horizontal added on top of the pattern.</summary>
	[Property, Group( "Recoil" )] public float RecoilRandomSide { get; set; } = 0f;

	// ⛔ `RecoilPatternDrift` LIVED HERE AND HAS NO READERS LEFT. It set the frequency of the
	// sine that drove horizontal recoil; horizontal is purely random per shot as of
	// 2026-09-14, so nothing consumes it. Deleted rather than left in place for the same
	// reason `VictimImmunityTime` was deleted from ZombieAI this morning: a [Property] with a
	// plausible value, sitting among fields that work, is a knob someone will eventually tune
	// and then report a change that never happened.
	//
	// ⚠️ THE 496 PREFABS STILL CARRY THE KEY and that is harmless — an unknown key in a prefab
	// is dropped on load. Re-adding the property would resurrect the value, so a future
	// learnable-pattern mode gets its authored frequencies back for free.

	/// <summary>0-1. How much of the accumulated recoil is returned automatically.</summary>
	[Property, Group( "Recoil" )] public float RecoilAutoControl { get; set; } = 0f;

	/// <summary>Accumulator decay per second once firing stops.</summary>
	[Property, Group( "Recoil" )] public float RecoilDissipationRate { get; set; } = 0f;

	/// <summary>Seconds of not firing before the accumulator resets to zero.</summary>
	[Property, Group( "Recoil" )] public float RecoilResetTime { get; set; } = 0f;

	/// <summary>
	/// Seconds for the view to travel back down to where it was aiming before the
	/// shot. 0 = no recovery, the climb is permanent until the player pulls down.
	///
	/// ⚠️ NOT the same as RecoilDissipationRate. Dissipation drains the internal
	/// ACCUMULATOR (how big the next kick will be); this returns the CAMERA. A
	/// weapon can have a fast-resetting pattern that never re-centres, or a slow
	/// pattern that snaps back instantly — they are independent axes of feel.
	/// </summary>
	[Property, Group( "Recoil" )] public float RecoilRecoveryTime { get; set; } = 0f;

	// ── ARC9 visual recoil ───────────────────────────────────────────────────
	//
	// ⛔ THIS IS NOT SCREEN SHAKE AND NOT AIM RECOIL. Shake moves the CAMERA; aim
	// recoil moves where you are POINTING. This moves only the WEAPON MODEL in
	// your hands — the gun rotates and drives back toward you while your aim stays
	// exactly where it was. It is what makes a gun feel like it has mass, and SWB
	// had no equivalent at all.

	[Property, Group( "Visual recoil" )] public bool UseVisualRecoil { get; set; } = false;

	/// <summary>Muzzle rise of the MODEL, degrees.</summary>
	[Property, Group( "Visual recoil" )] public float VisualRecoilUp { get; set; } = 0f;

	/// <summary>Sideways twist of the model, degrees.</summary>
	[Property, Group( "Visual recoil" )] public float VisualRecoilSide { get; set; } = 0f;

	/// <summary>Roll about the barrel, degrees.</summary>
	[Property, Group( "Visual recoil" )] public float VisualRecoilRoll { get; set; } = 0f;

	/// <summary>How far the weapon drives BACK toward the eye, units.</summary>
	[Property, Group( "Visual recoil" )] public float VisualRecoilPunch { get; set; } = 0f;

	/// <summary>
	/// How much the gun MODEL kicks on screen, as a multiplier on all of it — the rise and lean that follow the
	/// aim kick (or the authored random ones) and the pushback. 1 is as it is, 0 holds the model still; the aim
	/// itself is not touched. Written by the Kitbash Editor's Stats panel ("visual recoil").
	/// </summary>
	[Property, Group( "Visual recoil" )] public float VisualRecoilScale { get; set; } = 1f;

	[Property, Group( "Visual recoil" )] public float VisualRecoilUpMultSights { get; set; } = 1f;
	[Property, Group( "Visual recoil" )] public float VisualRecoilSideMultSights { get; set; } = 1f;

	/// <summary>Seconds for the model to settle back to its pose.</summary>
	[Property, Group( "Visual recoil" )] public float VisualRecoilRecovery { get; set; } = 0.15f;

	/// <summary>Extra first-shot punch, on top of the per-shot kick.</summary>
	[Property, Group( "Recoil" )] public float RecoilKick { get; set; } = 0f;

	/// <summary>
	/// How much material a bullet can pierce, in units. ARC9 authors this as a
	/// distance (`SWEP.Penetration = 4 * 39` = 4 metres of material).
	///
	/// ⚠️ SWB's `Penetration` is a BOOL — pierce or do not. This adds the budget:
	/// each surface crossed costs its thickness, and the bullet stops when spent.
	/// 0 means "use the bool as before, unlimited depth".
	/// </summary>
	[Property, Group( "Penetration" )] public float PenetrationDepth { get; set; } = 0f;

	/// <summary>Damage retained after piercing one surface (0-1).</summary>
	[Property, Group( "Penetration" )] public float PenetrationDamageMult { get; set; } = 1f;

	// ── ARC9 parity: hitbox multipliers + range falloff ──────────────────────
	//
	// ⛔ SWB APPLIES ONE FLAT `Damage` AT ANY RANGE AND ANY HIT LOCATION. ARC9
	// authors `BodyDamageMults` per hit group and interpolates DamageMax→DamageMin
	// across RangeMin→RangeMax. Without the first, a zombies mode has no headshot
	// economy and no skill loop; without the second, weapons differ only by RPM.
	//
	// ⚠️ Defaults are deliberately NO-OPS (mult 1, falloff 0) so every existing
	// weapon behaves exactly as before until its prefab opts in.

	/// <summary>Damage multiplier when the hitbox is tagged <see cref="HeadTag"/>.</summary>
	[Property, Group( "Damage" )] public float HeadMultiplier { get; set; } = 1f;

	/// <summary>Damage multiplier for hitboxes tagged arm/leg.</summary>
	[Property, Group( "Damage" )] public float LimbMultiplier { get; set; } = 1f;

	[Property, Group( "Damage" )] public string HeadTag { get; set; } = "head";

	/// <summary>Full damage out to here (units). 0 disables falloff entirely.</summary>
	[Property, Group( "Damage" )] public float FalloffStart { get; set; } = 0f;

	/// <summary>Damage has dropped to <see cref="FalloffMultiplier"/> by here.</summary>
	[Property, Group( "Damage" )] public float FalloffEnd { get; set; } = 0f;

	/// <summary>Fraction of Damage remaining at and beyond FalloffEnd.</summary>
	[Property, Group( "Damage" )] public float FalloffMultiplier { get; set; } = 1f;

	/// <summary>
	/// Damage for one bullet, given how far it travelled and what it hit.
	///
	/// ⚠️ Falloff is applied BEFORE the hit-group multiplier, matching ARC9: a
	/// distant headshot is a multiple of the reduced damage, not of the full
	/// damage. Doing it the other way makes long-range headshots hit for full.
	/// </summary>
	/// <summary>
	/// Flat scale on this weapon's damage. 1 = as authored.
	///
	/// ⚠️ PACK-A-PUNCH LIVES HERE, and nowhere else. `DamageFor` is the single
	/// choke point both bullet paths (hitscan and physical) run through, so one
	/// multiplier read here upgrades every firing mode at once — where scaling
	/// `Damage` itself would be destructive (the authored value is the thing a
	/// re-equip restores from) and patching the two call sites would leave the
	/// next bullet type someone adds un-upgraded.
	///
	/// ⚠️ Applied to the BASE, before falloff and hit-group multipliers, so a
	/// PaP'd headshot at range is still `base x pap x falloff x head` rather than
	/// having the upgrade quietly cancel the falloff.
	/// </summary>
	public float DamageMultiplier { get; set; } = 1f;

	/// <summary>
	/// WEAPON RARITY's damage multiplier. `Rarity.Step ^ tier` — x1 Common through
	/// x5.06 Legendary. Assigned per equip by NZPlayer.ApplyStoredUpgrades.
	///
	/// ⛔ A SEPARATE FIELD FROM DamageMultiplier, DELIBERATELY, AND NOT AN
	/// OPTIMISATION AWAY FROM ONE. Two places in Weapon.Shoot read
	/// `DamageMultiplier > 1.01f` as "this gun is PACKED" — it gates the
	/// Pack-a-Punch shoot sound and the purple, enlarged muzzle flash. Multiplying
	/// rarity into that field would give an unpacked Legendary the entire
	/// Pack-a-Punch presentation, and nothing downstream could separate them again.
	///
	/// ⚠️ They MULTIPLY: rarity is independent of packing, exactly as in the
	/// original, so a box-rolled gun deals its rarity damage whether packed or not.
	/// The full chain is `base x pap x rarity x falloff x hitgroup`, and then the
	/// shooter's damage perks on top in Health.OnDamage.
	/// </summary>
	public float RarityMultiplier { get; set; } = 1f;

	/// <summary>
	/// HAS THIS GUN BEEN THROUGH THE PACK-A-PUNCH. Assigned per equip by
	/// `NZPlayer.ApplyStoredUpgrades`, beside the multiplier it used to be inferred from.
	///
	/// ⛔ FOUR PLACES USED TO ASK `DamageMultiplier > 1.01f` AND TWO OF THEM WERE WRONG. That
	/// field is not a constant — `Weapon.Shoot` multiplies Double Tap's charged trigger, Speed
	/// Cola's M3 adrenaline and Micro-Burst into it for the duration of the bullet loop, and
	/// restores it in a `finally`. The shoot sound and the muzzle flash happen to run BEFORE that
	/// window opens, which is why they were right; the tracer tint and the network relay run INSIDE
	/// it, per bullet. So an UNPACKED gun under a charged trigger drew violet tracers — and once
	/// shots started being relayed, showed every other player the full Pack-a-Punch presentation.
	///
	/// ⚠️ A FLAG, NOT A THRESHOLD, so no future damage term can accidentally mean "packed"
	/// again. The correctness of those four sites no longer depends on where in the method they sit.
	///
	/// ⚠️ STILL NOT `PapLevelFor`. The question is asked per bullet on the shooting path and
	/// the answer only changes on equip — which is exactly when this is written.
	/// </summary>
	public bool IsPacked { get; set; }

	/// <summary>
	/// WHICH Pack-a-Punch tier, 1-5. 0 when unpacked.
	///
	/// ⚠️ BESIDE `IsPacked` RATHER THAN REPLACING IT, and both are written in the same place for
	/// the same reason. Four sites ask "is this packed" as a yes/no — the shoot sound, the relay
	/// gate — and deriving that from `PapLevel > 0` everywhere would make every one of them
	/// depend on the tier numbering staying 1-based.
	///
	/// ⚠️ SAME LIFECYCLE AS `IsPacked`: written on equip by `NZPlayer.ApplyStoredUpgrades`, read
	/// per bullet. The answer cannot change between those two moments.
	/// </summary>
	public int PapLevel { get; set; }

	public float DamageFor( float distance, string[] hitTags )
	{
		// ⚠️ BOTH MULTIPLIERS ON THE BASE, before falloff and hit groups — the same
		// reasoning DamageMultiplier's own note gives: a Legendary headshot at range
		// stays `base x pap x rarity x falloff x head` rather than having an upgrade
		// quietly cancel the falloff.
		var dmg = Damage * DamageMultiplier * RarityMultiplier;

		if ( FalloffEnd > FalloffStart && distance > FalloffStart )
		{
			var t = System.Math.Clamp( (distance - FalloffStart) / (FalloffEnd - FalloffStart), 0f, 1f );
			dmg *= MathX.Lerp( 1f, FalloffMultiplier, t );
		}

		if ( hitTags is not null && hitTags.Length > 0 )
		{
			if ( System.Array.IndexOf( hitTags, HeadTag ) >= 0 )
				dmg *= HeadMultiplier;
			else if ( System.Array.Exists( hitTags, t => t is "arm" or "leg" or "limb" ) )
				dmg *= LimbMultiplier;
		}

		return dmg;
	}

	/// <summary>Bullet impact force</summary>
	[Property, Group( "Bullets" )] public float Force { get; set; } = 0.1f;

	/// <summary>Bullet hit flinch</summary>
	[Property, Group( "Bullets" )] public float HitFlinch { get; set; } = 1.25f;

	/// <summary>Bullet penetration</summary>
	[Property, Group( "Bullets" )] public bool Penetration { get; set; } = false;

	/// <summary>Whether hitscan bullets can ricochet off qualifying surfaces (e.g. metal) when hit at a shallow angle</summary>
	[Property, Group( "Bullets" )] public bool Ricochet { get; set; } = true;

	/// <summary>Max angle (degrees, measured from the surface plane) at which a ricochet can occur. Lower = only very shallow grazing hits bounce.</summary>
	[Property, Group( "Bullets" )] public float RicochetAngle { get; set; } = 30f;

	/// <summary>Chance (0-1) that an eligible grazing hit actually ricochets</summary>
	[Property, Group( "Bullets" )] public float RicochetChance { get; set; } = 0.33f;

	/// <summary>Max number of times a single bullet can ricochet</summary>
	[Property, Group( "Bullets" )] public int MaxRicochets { get; set; } = 3;

	/// <summary>Weapon spread</summary>
	[Property, Group( "Bullets" )] public float Spread { get; set; } = 0.1f;

	/// <summary>Weapon recoil</summary>
	[Property, Group( "Bullets" )] public float Recoil { get; set; } = 0.1f;

	/// <summary>Rate Per Minute, firing speed (higher is faster)</summary>
	[Property, Group( "Bullets" )] public int RPM { get; set; } = 200;

	/// <summary>Screenshake per shot</summary>
	[Property, Group( "Bullets" )] public ScreenShake ScreenShake { get; set; }

	/// <summary>Weapon firing type</summary>
	[Property, Group( "Bullets" )] public FiringType FiringType { get; set; } = FiringType.semi;

	// Animations //

	/// <summary>Animation used for shooting</summary>
	[Property, Group( "Animations" )] public string ShootAnim { get; set; } = "fire";

	/// <summary>Animation used for shooting the last bullet</summary>
	[Property, Group( "Animations" )] public string ShootEmptyAnim { get; set; } = "";

	/// <summary>Animation used for shooting while aiming</summary>
	[Property, Group( "Animations" )] public string ShootAimedAnim { get; set; }

	// Sounds //

	/// <summary>Firing sound when clip is empty</summary>
	[Property, Group( "Sounds" )] public SoundEvent DryShootSound { get; set; }

	/// <summary>Firing sound</summary>
	[Property, Group( "Sounds" )] public SoundEvent ShootSound { get; set; }

	/// <summary>Set when the shot is built in code: `ShootSound` is then its template. See GunCue.</summary>
	[Property, Group( "Sounds" )] public GunCue ShootSoundCue { get; set; }

	// Particles //

	/// <summary> View Model particle scale</summary>
	[Property, Title( "View Model Scale" ), Group( "Particles" )] public float VMParticleScale { get; set; } = 1f;

	/// <summary> World Model particle scale (BulletEject + BulletTracer)</summary>
	[Property, Title( "World Model Scale" ), Group( "Particles" )] public float WMParticleScale { get; set; } = 1f;

	/// <summary>World Model particle scale for the muzzle effects (MuzzleFlash + BarrelSmoke)</summary>
	[Property, Title( "World Model Muzzle Scale" ), Group( "Particles" )] public float WMMuzzleParticleScale { get; set; } = 1f;

	/// <summary>Particle used for bullet ejection</summary>
	[Property, Group( "Particles" )] public PrefabScene BulletEjectParticle { get; set; } = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( "prefabs/particles/shell/shelleject_9mm.prefab" ) );

	/// <summary>Particle used for the muzzle flash</summary>
	[Property, Group( "Particles" )] public PrefabScene MuzzleFlashParticle { get; set; } = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( "prefabs/particles/muzzle/muzzleflash.prefab" ) );

	/// <summary>Particle used for the barrel smoke</summary>
	[Property, Group( "Particles" )] public PrefabScene BarrelSmokeParticle { get; set; } = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( "prefabs/particles/muzzle/barrelsmoke.prefab" ) );

	/// <summary>Particle used for the barrel smoke</summary>
	[Property, Group( "Particles" )] public PrefabScene BulletTracerParticle { get; set; } = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( "prefabs/particles/tracer/tracer.prefab" ) );
}