using Barotrauma.Abilities; using Barotrauma.Networking; using FarseerPhysics; using FarseerPhysics.Dynamics; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.Linq; namespace Barotrauma.Items.Components { partial class RangedWeapon : ItemComponent { private float reload; public float ReloadTimer { get; private set; } private Vector2 barrelPos; [Serialize("0.0,0.0", IsPropertySaveable.No, description: "The position of the barrel as an offset from the item's center (in pixels). Determines where the projectiles spawn.")] public string BarrelPos { get { return XMLExtensions.Vector2ToString(ConvertUnits.ToDisplayUnits(barrelPos)); } set { barrelPos = ConvertUnits.ToSimUnits(XMLExtensions.ParseVector2(value)); } } [Serialize(1.0f, IsPropertySaveable.No, description: "How long the user has to wait before they can fire the weapon again (in seconds).")] public float Reload { get { return reload; } set { reload = Math.Max(value, 0.0f); } } [Serialize(0f, IsPropertySaveable.No, description: "Weapons skill requirement to reload at normal speed.")] public float ReloadSkillRequirement { get; set; } [Serialize(1.0f, IsPropertySaveable.No, description: "Reload time at 0 skill level. Reload time scales with skill level up to the Weapons skill requirement.")] public float ReloadNoSkill { get; set; } [Serialize(false, IsPropertySaveable.No, description: "Tells the AI to hold the trigger down when it uses this weapon")] public bool HoldTrigger { get; set; } [Serialize(1, IsPropertySaveable.No, description: "How many projectiles the weapon launches when fired once.")] public int ProjectileCount { get; set; } [Serialize(0.0f, IsPropertySaveable.No, description: "Random spread applied to the firing angle of the projectiles when used by a character with sufficient skills to use the weapon (in degrees).")] public float Spread { get; set; } [Serialize(0.0f, IsPropertySaveable.No, description: "Random spread applied to the firing angle of the projectiles when used by a character with insufficient skills to use the weapon (in degrees).")] public float UnskilledSpread { get; set; } [Serialize(0.0f, IsPropertySaveable.No, description: "The impulse applied to the physics body of the projectile (the higher the impulse, the faster the projectiles are launched). Sum of weapon + projectile.")] public float LaunchImpulse { get; set; } [Serialize(0.0f, IsPropertySaveable.Yes, description: "Percentage of damage mitigation ignored when hitting armored body parts (deflecting limbs). Sum of weapon + projectile."), Editable(MinValueFloat = 0.0f, MaxValueFloat = 1f)] public float Penetration { get; private set; } [Serialize(1f, IsPropertySaveable.Yes, description: "Weapon's damage modifier")] public float WeaponDamageModifier { get; private set; } [Serialize(0f, IsPropertySaveable.Yes, description: "The time required for a charge-type turret to charge up before able to fire.")] public float MaxChargeTime { get; private set; } [Serialize(defaultValue: 1f, IsPropertySaveable.Yes, description: "Penalty multiplier to reload time when dual-wielding.")] public float DualWieldReloadTimePenaltyMultiplier { get; private set; } [Serialize(defaultValue: 0f, IsPropertySaveable.Yes, description: "Additive penalty to accuracy (spread angle) when dual-wielding.")] public float DualWieldAccuracyPenalty { get; private set; } private readonly IReadOnlySet suitableProjectiles; private enum ChargingState { Inactive, WindingUp, WindingDown, } private ChargingState currentChargingState; public Vector2 TransformedBarrelPos { get { Matrix bodyTransform = Matrix.CreateRotationZ(item.body == null ? item.RotationRad : item.body.Rotation); Vector2 flippedPos = barrelPos; if (item.body != null && item.body.Dir < 0.0f) { flippedPos.X = -flippedPos.X; } return Vector2.Transform(flippedPos, bodyTransform) * item.Scale; } } public Projectile LastProjectile { get; private set; } private float currentChargeTime; private bool tryingToCharge; public RangedWeapon(Item item, ContentXElement element) : base(item, element) { item.IsShootable = true; if (element.Parent is { } parent) { item.RequireAimToUse = parent.GetAttributeBool(nameof(item.RequireAimToUse), true); } characterUsable = true; suitableProjectiles = element.GetAttributeIdentifierArray(nameof(suitableProjectiles), Array.Empty()).ToHashSet(); if (ReloadSkillRequirement > 0 && ReloadNoSkill <= reload) { DebugConsole.AddWarning($"Invalid XML at {item.Name}: ReloadNoSkill is lower or equal than it's reload skill, despite having ReloadSkillRequirement.", item.Prefab.ContentPackage); } InitProjSpecific(element); } partial void InitProjSpecific(ContentXElement rangedWeaponElement); public override void Equip(Character character) { //clamp above 1 to prevent rapid-firing by swapping weapons ReloadTimer = Math.Max(Math.Min(reload, 1.0f), ReloadTimer); IsActive = true; } public override void Update(float deltaTime, Camera cam) { ReloadTimer -= deltaTime; if (ReloadTimer < 0.0f) { ReloadTimer = 0.0f; if (MaxChargeTime <= 0f) { IsActive = false; return; } } float previousChargeTime = currentChargeTime; float chargeDeltaTime = tryingToCharge && ReloadTimer <= 0f ? deltaTime : -deltaTime; currentChargeTime = Math.Clamp(currentChargeTime + chargeDeltaTime, 0f, MaxChargeTime); tryingToCharge = false; if (currentChargeTime == 0f) { currentChargingState = ChargingState.Inactive; } else if (currentChargeTime < previousChargeTime) { currentChargingState = ChargingState.WindingDown; } else { // if we are charging up or at maxed charge, remain winding up currentChargingState = ChargingState.WindingUp; } UpdateProjSpecific(deltaTime); } partial void UpdateProjSpecific(float deltaTime); private float GetSpread(Character user) { float degreeOfFailure = MathHelper.Clamp(1.0f - DegreeOfSuccess(user), 0.0f, 1.0f); degreeOfFailure *= degreeOfFailure; float spread = MathHelper.Lerp(Spread, UnskilledSpread, degreeOfFailure) / (1f + user.GetStatValue(StatTypes.RangedSpreadReduction)); if (user.IsDualWieldingRangedWeapons()) { spread += Math.Max(0f, ApplyDualWieldPenaltyReduction(user, DualWieldAccuracyPenalty, neutralValue: 0f)); } return MathHelper.ToRadians(spread); } /// /// Lerps between the original penalty and a neutral value, which should be 1 for multipliers and 0 for additive penalties. /// /// The character to get stat values from /// The original penalty value /// Neutral value to lerp towards. Should be 1 for multipliers and 0 for additives. /// private static float ApplyDualWieldPenaltyReduction(Character character, float originalPenalty, float neutralValue) { float statAdjustmentPrc = character.GetStatValue(StatTypes.DualWieldingPenaltyReduction); statAdjustmentPrc = MathHelper.Clamp(statAdjustmentPrc, 0f, 1f); float reducedPenaltyMultiplier = MathHelper.Lerp(originalPenalty, neutralValue, statAdjustmentPrc); return reducedPenaltyMultiplier; } private readonly List ignoredBodies = new List(); public override bool Use(float deltaTime, Character character = null) { tryingToCharge = true; if (character == null || character.Removed) { return false; } if ((item.RequireAimToUse && !character.IsKeyDown(InputType.Aim)) || ReloadTimer > 0.0f) { return false; } if (currentChargeTime < MaxChargeTime) { return false; } IsActive = true; float baseReloadTime = reload; float weaponSkill = character.GetSkillLevel("weapons"); bool applyReloadFailure = ReloadSkillRequirement > 0 && ReloadNoSkill > reload && weaponSkill < ReloadSkillRequirement; if (applyReloadFailure) { //Examples, assuming 40 weapon skill required: 1 - 40/40 = 0 ... 1 - 0/40 = 1 ... 1 - 20 / 40 = 0.5 float reloadFailure = MathHelper.Clamp(1 - (weaponSkill / ReloadSkillRequirement), 0, 1); baseReloadTime = MathHelper.Lerp(reload, ReloadNoSkill, reloadFailure); } if (character.IsDualWieldingRangedWeapons()) { baseReloadTime *= Math.Max(1f, ApplyDualWieldPenaltyReduction(character, DualWieldReloadTimePenaltyMultiplier, neutralValue: 1f)); } ReloadTimer = baseReloadTime / (1 + character?.GetStatValue(StatTypes.RangedAttackSpeed) ?? 0f); ReloadTimer /= 1f + item.GetQualityModifier(Quality.StatType.FiringRateMultiplier); currentChargeTime = 0f; var abilityRangedWeapon = new AbilityRangedWeapon(item); character.CheckTalents(AbilityEffectType.OnUseRangedWeapon, abilityRangedWeapon); if (item.AiTarget != null) { item.AiTarget.SoundRange = item.AiTarget.MaxSoundRange; item.AiTarget.SightRange = item.AiTarget.MaxSightRange; } float degreeOfFailure = 1.0f - DegreeOfSuccess(character); degreeOfFailure *= degreeOfFailure; if (degreeOfFailure > Rand.Range(0.0f, 1.0f)) { ApplyStatusEffects(ActionType.OnFailure, 1.0f, character); } for (int i = 0; i < ProjectileCount; i++) { Projectile projectile = FindProjectile(triggerOnUseOnContainers: true); if (projectile != null) { Vector2 barrelPos = TransformedBarrelPos + item.body.SimPosition; float rotation = (Item.body.Dir == 1.0f) ? Item.body.Rotation : Item.body.Rotation - MathHelper.Pi; float spread = GetSpread(character) * projectile.GetSpreadFromPool(); var lastProjectile = LastProjectile; if (lastProjectile != projectile) { lastProjectile?.Item.GetComponent()?.Snap(); } float damageMultiplier = (1f + item.GetQualityModifier(Quality.StatType.FirepowerMultiplier)) * WeaponDamageModifier; projectile.Launcher = item; ignoredBodies.Clear(); if (!projectile.DamageUser) { foreach (Limb l in character.AnimController.Limbs) { if (l.IsSevered) { continue; } ignoredBodies.Add(l.body.FarseerBody); } foreach (Item heldItem in character.HeldItems) { var holdable = heldItem.GetComponent(); if (holdable?.Pusher != null) { ignoredBodies.Add(holdable.Pusher.FarseerBody); } } } projectile.Shoot(character, character.AnimController.AimSourceSimPos, barrelPos, rotation + spread, ignoredBodies: ignoredBodies.ToList(), createNetworkEvent: false, damageMultiplier, LaunchImpulse); projectile.Item.GetComponent()?.Attach(Item, projectile.Item); if (projectile.Item.body != null) { if (i == 0) { Item.body.ApplyLinearImpulse(new Vector2((float)Math.Cos(projectile.Item.body.Rotation), (float)Math.Sin(projectile.Item.body.Rotation)) * Item.body.Mass * -50.0f, maxVelocity: NetConfig.MaxPhysicsBodyVelocity); } projectile.Item.body.ApplyTorque(projectile.Item.body.Mass * degreeOfFailure * 20.0f * projectile.GetSpreadFromPool()); } Item.RemoveContained(projectile.Item); } LastProjectile = projectile; } LaunchProjSpecific(); return true; } public override bool SecondaryUse(float deltaTime, Character character = null) { return characterUsable || character == null; } public Projectile FindProjectile(bool triggerOnUseOnContainers = false) { foreach (ItemContainer container in item.GetComponents()) { foreach (Item containedItem in container.Inventory.AllItemsMod) { if (containedItem == null) { continue; } Projectile projectile = containedItem.GetComponent(); if (IsSuitableProjectile(projectile)) { return projectile; } //projectile not found, see if the contained item contains projectiles var containedSubItems = containedItem.OwnInventory?.AllItemsMod; if (containedSubItems == null) { continue; } foreach (Item subItem in containedSubItems) { if (subItem == null) { continue; } Projectile subProjectile = subItem.GetComponent(); //apply OnUse statuseffects to the container in case it has to react to it somehow //(play a sound, spawn more projectiles, reduce condition...) if (triggerOnUseOnContainers && subItem.Condition > 0.0f) { subItem.GetComponent()?.Item.ApplyStatusEffects(ActionType.OnUse, 1.0f); } if (IsSuitableProjectile(subProjectile)) { return subProjectile; } } } } return null; } private bool IsSuitableProjectile(Projectile projectile) { if (projectile?.Item == null) { return false; } if (!suitableProjectiles.Any()) { return true; } return suitableProjectiles.Any(s => projectile.Item.Prefab.Identifier == s || projectile.Item.HasTag(s)); } partial void LaunchProjSpecific(); } class AbilityRangedWeapon : AbilityObject, IAbilityItem { public AbilityRangedWeapon(Item item) { Item = item; } public Item Item { get; set; } } }