using Barotrauma.Extensions; using Barotrauma.Networking; using FarseerPhysics; using FarseerPhysics.Dynamics; using Microsoft.Xna.Framework; using System; using System.Linq; namespace Barotrauma.Items.Components { partial class Rope : ItemComponent, IServerSerializable { private ISpatialEntity source; private Item target; private Vector2? launchDir; private float currentRopeLength; private void SetSource(ISpatialEntity source) { this.source = source; if (source is Limb sourceLimb) { sourceLimb.AttachedRope = this; float offset = sourceLimb.Params.GetSpriteOrientation() - MathHelper.PiOver2; launchDir = VectorExtensions.Forward(sourceLimb.body.TransformedRotation - offset * sourceLimb.character.AnimController.Dir); } } private void ResetSource() { if (source is Limb sourceLimb && sourceLimb.AttachedRope == this) { sourceLimb.AttachedRope = null; } source = null; } private float snapTimer; [Serialize(1.0f, IsPropertySaveable.No, description: "")] public float SnapAnimDuration { get; set; } private float raycastTimer; private const float RayCastInterval = 0.2f; [Serialize(0.0f, IsPropertySaveable.No, description: "How much force is applied to pull the projectile the rope is attached to.")] public float ProjectilePullForce { get; set; } [Serialize(0.0f, IsPropertySaveable.No, description: "How much force is applied to pull the target the rope is attached to.")] public float TargetPullForce { get; set; } [Serialize(0.0f, IsPropertySaveable.No, description: "How much force is applied to pull the source the rope is attached to.")] public float SourcePullForce { get; set; } [Serialize(1000.0f, IsPropertySaveable.No, description: "How far the source item can be from the projectile until the rope breaks.")] public float MaxLength { get; set; } [Serialize(200.0f, IsPropertySaveable.No, description: "At which distance the user stops pulling the target?")] public float MinPullDistance { get; set; } [Serialize(360.0f, IsPropertySaveable.No, description: "The maximum angle from the source to the target until the rope breaks.")] public float MaxAngle { get; set; } [Serialize(true, IsPropertySaveable.No, description: "Should the rope snap when it collides with a structure/submarine (if not, it will just go through it).")] public bool SnapOnCollision { get; set; } [Serialize(true, IsPropertySaveable.No, description: "Should the rope snap when the character drops the aim?")] public bool SnapWhenNotAimed { get; set; } [Serialize(true, IsPropertySaveable.No, description: "Should the rope snap when the weapon it was fired from is fired again? I.e. can there be multiple ropes coming from the weapon at the same time?")] public bool SnapWhenWeaponFiredAgain { get; set; } [Serialize(0.9f, IsPropertySaveable.No, description: "Multiplier for the length of the barrel when determining where the rope should start from.")] public float BarrelLengthMultiplier { get; set; } [Serialize(30.0f, IsPropertySaveable.No, description: "How much mass is required for the target to pull the source towards it. Static and kinematic targets are always treated heavy enough.")] public float TargetMinMass { get; set; } [Serialize(false, IsPropertySaveable.No)] public bool LerpForces { get; set; } [Serialize(true, IsPropertySaveable.No, description: "Should the force be dynamically adjusted to make it more difficult for targets to escape the pull?")] public bool IncreaseForceForEscapingTargets { get; set; } private bool isReelingIn; private bool snapped; public bool Snapped { get { return snapped; } set { if (snapped == value) { return; } if (GameMain.NetworkMember != null) { if (GameMain.NetworkMember.IsClient) { return; } else { #if SERVER item.CreateServerEvent(this); #endif } } snapped = value; if (!snapped) { snapTimer = 0; } else if (target != null && source != null && target != source) { #if CLIENT // Play a sound at both ends. Initially tested playing the sound in the middle when the rope snaps in the middle, // but I think it's more important to ensure that the players hear the sound. PlaySound(snapSound, source.WorldPosition); PlaySound(snapSound, target.WorldPosition); #endif } } } public Rope(Item item, ContentXElement element) : base(item, element) { InitProjSpecific(element); } partial void InitProjSpecific(ContentXElement element); public void Snap() => Snapped = true; public void Attach(ISpatialEntity source, Item target) { System.Diagnostics.Debug.Assert(source != null); System.Diagnostics.Debug.Assert(target != null); this.target = target; SetSource(source); Snapped = false; ApplyStatusEffects(ActionType.OnUse, 1.0f, worldPosition: item.WorldPosition); IsActive = true; } public override void Update(float deltaTime, Camera cam) { UpdateProjSpecific(); isReelingIn = false; Character user = item.GetComponent()?.User; if (source == null || target == null || target.Removed || source is Entity { Removed: true } || source is Limb { Removed: true } || user is { Removed: true }) { ResetSource(); target = null; IsActive = false; return; } if (Snapped) { snapTimer += deltaTime; if (snapTimer >= SnapAnimDuration) { IsActive = false; } return; } Vector2 diff = target.WorldPosition - GetSourcePos(useDrawPosition: false); float lengthSqr = diff.LengthSquared(); if (lengthSqr > MaxLength * MaxLength) { Snap(); return; } if (MaxAngle < 180 && lengthSqr > 2500) { launchDir ??= diff; float angle = MathHelper.ToDegrees(launchDir.Value.Angle(diff)); if (angle > MaxAngle) { Snap(); return; } } #if CLIENT item.ResetCachedVisibleSize(); #endif var projectile = target.GetComponent(); if (projectile == null) { return; } if (SnapOnCollision) { raycastTimer += deltaTime; if (raycastTimer > RayCastInterval) { if (Submarine.PickBody(ConvertUnits.ToSimUnits(source.WorldPosition), ConvertUnits.ToSimUnits(target.WorldPosition), collisionCategory: Physics.CollisionLevel | Physics.CollisionWall, customPredicate: (Fixture f) => { foreach (Body body in projectile.Hits) { Submarine alreadyHitSub = null; if (body.UserData is Structure hitStructure) { alreadyHitSub = hitStructure.Submarine; } else if (body.UserData is Submarine hitSub) { alreadyHitSub = hitSub; } if (alreadyHitSub != null) { if (f.Body?.UserData is MapEntity me && me.Submarine == alreadyHitSub) { return false; } if (f.Body?.UserData as Submarine == alreadyHitSub) { return false; } } } Submarine targetSub = projectile.StickTarget?.UserData as Submarine ?? target.Submarine; if (f.Body?.UserData is MapEntity mapEntity && mapEntity.Submarine != null) { if (mapEntity.Submarine == targetSub || mapEntity.Submarine == source.Submarine) { return false; } } else if (f.Body?.UserData is Submarine sub) { if (sub == targetSub || sub == source.Submarine) { return false; } } return true; }) != null) { Snap(); return; } raycastTimer = 0.0f; } } Vector2 forceDir = diff; currentRopeLength = diff.Length(); if (currentRopeLength > 0.001f) { forceDir = Vector2.Normalize(forceDir); } if (Math.Abs(ProjectilePullForce) > 0.001f) { projectile.Item?.body?.ApplyForce(-forceDir * ProjectilePullForce); } if (projectile.StickTarget != null) { float targetMass = float.MaxValue; Character targetCharacter = null; switch (projectile.StickTarget.UserData) { case Limb targetLimb: targetCharacter = targetLimb.character; targetMass = targetLimb.ragdoll.Mass; break; case Character character: targetCharacter = character; targetMass = character.Mass; break; case Item _: targetMass = projectile.StickTarget.Mass; break; } if (projectile.StickTarget.BodyType != BodyType.Dynamic) { targetMass = float.MaxValue; } // Currently can only apply pull forces to the source, when it's a character, not e.g. when the item would be auto-operated by an AI. Might have to change this. if (user != null) { if (!snapped && //user can only hold on to the rope if it was launched from a holdable item, or by something else than an item (limb?) (projectile.Launcher == null || projectile.Launcher.GetComponent() != null)) { user.AnimController.HoldToRope(); if (targetCharacter != null) { targetCharacter.AnimController.DragWithRope(); } if (user.InWater) { user.AnimController.HangWithRope(); } } if (Math.Abs(SourcePullForce) > 0.001f && targetMass > TargetMinMass) { // This should be the main collider. var sourceBody = GetBodyToPull(source); if (sourceBody != null) { PhysicsBody targetBody = GetBodyToPull(target); if (sourceBody.UserData is Character) { isReelingIn = user.InWater && user.IsRagdolled || !user.InWater && targetCharacter is { IsIncapacitated: false }; if (isReelingIn) { float pullForce = SourcePullForce; if (!user.InWater) { // Apply a tiny amount to the character holding the rope, so that the connection "feels" more real. pullForce *= 0.1f; } float lengthFactor = MathUtils.InverseLerp(0, MaxLength / 2, currentRopeLength); float force = LerpForces ? MathHelper.Lerp(0, pullForce, lengthFactor) : pullForce; sourceBody.ApplyForce(forceDir * force); // Take the target velocity into account. if (targetBody != null) { if (targetCharacter != null) { if (targetBody.LinearVelocity != Vector2.Zero && sourceBody.LinearVelocity != Vector2.Zero) { Vector2 targetDir = Vector2.Normalize(targetBody.LinearVelocity); float movementDot = Vector2.Dot(Vector2.Normalize(sourceBody.LinearVelocity), targetDir); if (movementDot < 0) { // Pushing to a different dir -> add some counter force const float multiplier = 5; float inverseLengthFactor = MathHelper.Lerp(1, 0, lengthFactor); sourceBody.ApplyForce(targetBody.LinearVelocity * Math.Min(targetBody.Mass * multiplier, 250) * sourceBody.Mass * -movementDot * inverseLengthFactor); } float forceDot = Vector2.Dot(forceDir, targetDir); if (forceDot > 0) { // Pulling to the same dir -> add extra force float targetSpeed = targetBody.LinearVelocity.Length(); const float multiplier = 25; sourceBody.ApplyForce(forceDir * targetSpeed * sourceBody.Mass * multiplier * forceDot * lengthFactor); } float colliderMainLimbDistance = Vector2.Distance(sourceBody.SimPosition, user.AnimController.MainLimb.SimPosition); const float minDist = 1; const float maxDist = 10; if (colliderMainLimbDistance > minDist && sourceBody.UserData is not Submarine) { // Move the ragdoll closer to the collider, if it's too far (the correction force in HumanAnimController is not enough -> the ragdoll would lag behind and get teleported). float correctionForce = MathHelper.Lerp(10.0f, NetConfig.MaxPhysicsBodyVelocity, MathUtils.InverseLerp(minDist, maxDist, colliderMainLimbDistance)); Vector2 targetPos = sourceBody.SimPosition + new Vector2((float)Math.Sin(-sourceBody.Rotation), (float)Math.Cos(-sourceBody.Rotation)) * 0.4f; user.AnimController.MainLimb.MoveToPos(targetPos, correctionForce); } } } else { sourceBody.ApplyForce(targetBody.LinearVelocity * sourceBody.Mass); } } } } else { float distance = Vector2.Distance(source.WorldPosition, target.WorldPosition); float force = LerpForces ? MathHelper.Lerp(0, SourcePullForce, MathUtils.InverseLerp(0, MaxLength / 2, distance)) : SourcePullForce; sourceBody.ApplyForce(forceDir * force); } } } } if (Math.Abs(TargetPullForce) > 0.001f && user is not { IsRagdolled: true}) { PhysicsBody targetBody = GetBodyToPull(target); if (targetBody == null) { return; } bool lerpForces = LerpForces; float maxVelocity = NetConfig.MaxPhysicsBodyVelocity * 0.25f; // The distance where we start pulling with max force. float maxPullDistance = MaxLength / 3; float minPullDistance = MinPullDistance; const float absoluteMinPullDistance = 50; if (targetCharacter != null) { if (targetCharacter.IsRagdolled || targetCharacter.IsUnconscious) { if (!targetCharacter.InWater) { // Limits the velocity of ragdolled characters on ground/air, because otherwise they tend to move with too high forces. maxVelocity = NetConfig.MaxPhysicsBodyVelocity * 0.075f; } } else { // Target alive and kicking -> Use the absolute min pull distance and full forces to pull. // Keep some lerping, because it results into smoothing when the target is close by. minPullDistance = absoluteMinPullDistance; maxPullDistance = 200; } } minPullDistance = MathHelper.Max(minPullDistance, absoluteMinPullDistance); if (currentRopeLength < minPullDistance) { return; } maxPullDistance = MathHelper.Max(minPullDistance * 2, maxPullDistance); float force = lerpForces ? MathHelper.Lerp(0, TargetPullForce, MathUtils.InverseLerp(minPullDistance, maxPullDistance, currentRopeLength)) : TargetPullForce; targetBody.ApplyForce(-forceDir * force, maxVelocity); AnimController targetRagdoll = targetCharacter?.AnimController; if (targetRagdoll?.Collider != null) { isReelingIn = true; if (targetRagdoll.InWater || targetRagdoll.OnGround) { float forceMultiplier = 1; if (!targetCharacter.IsRagdolled && !targetCharacter.IsIncapacitated && IncreaseForceForEscapingTargets) { // Pulling the main collider requires higher forces when the target is trying to move away. Vector2 targetMovement = targetCharacter.AnimController.TargetMovement; float dot = Vector2.Dot(Vector2.Normalize(targetMovement), forceDir); if (dot > 0) { const float constMultiplier = 2.5f; float targetVelocity = targetMovement.Length(); float massFactor = Math.Max((float)Math.Log(targetCharacter.Mass / 10), 1); forceMultiplier = Math.Max(targetVelocity * massFactor * constMultiplier * dot, 1); } } targetRagdoll.Collider.ApplyForce(-forceDir * force * forceMultiplier, maxVelocity); } } } } } partial void UpdateProjSpecific(); public override void UpdateBroken(float deltaTime, Camera cam) { base.UpdateBroken(deltaTime, cam); if (Snapped) { snapTimer += deltaTime; if (snapTimer >= SnapAnimDuration) { IsActive = false; } } } /// /// Get the position the rope starts from (taking into account barrel positions if needed) /// /// Should the interpolated draw position be used? If not, the WorldPosition is used. private Vector2 GetSourcePos(bool useDrawPosition = false) { Vector2 sourcePos = source.WorldPosition; if (source is Item sourceItem) { if (useDrawPosition) { sourcePos = sourceItem.DrawPosition; } if (!sourceItem.Removed) { if (sourceItem.GetComponent() is { } turret) { sourcePos = new Vector2(sourceItem.WorldRect.X + turret.TransformedBarrelPos.X, sourceItem.WorldRect.Y - turret.TransformedBarrelPos.Y); } else if (sourceItem.GetComponent() is { } weapon) { sourcePos += ConvertUnits.ToDisplayUnits(weapon.TransformedBarrelPos); } } } else if (useDrawPosition && source is Limb sourceLimb && sourceLimb.body != null) { sourcePos = sourceLimb.body.DrawPosition; } return sourcePos; } private static PhysicsBody GetBodyToPull(ISpatialEntity target) { if (target is Item targetItem) { if (targetItem.ParentInventory is CharacterInventory { Owner: Character ownerCharacter }) { if (ownerCharacter.Removed) { return null; } return ownerCharacter.AnimController.Collider; } var projectile = targetItem.GetComponent(); if (projectile is { StickTarget: not null }) { return projectile.StickTarget.UserData switch { Structure structure => structure.Submarine?.PhysicsBody, Submarine sub => sub.PhysicsBody, Item item => item.body, Limb limb => limb.body, _ => null }; } if (targetItem.body != null) { return targetItem.body; } if (targetItem.StaticFixtures.Any() && targetItem.Submarine != null) { return targetItem.Submarine.PhysicsBody; } } else if (target is Limb targetLimb) { return targetLimb.body; } return null; } } }