]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Grappling rework - Grappling hooks are now physics-driven (#42409)
authordeathride58 <deathride58@users.noreply.github.com>
Thu, 22 Jan 2026 22:15:01 +0000 (17:15 -0500)
committerGitHub <noreply@github.com>
Thu, 22 Jan 2026 22:15:01 +0000 (22:15 +0000)
* Grappling rework - Grappling hooks are now physics-based

* still have no idea wtf is going on with portals but fixed a few bugs + cleanup

* bonus fixes + prep for optional-but-recommended engine PR

* dropkicking a stray comment outta here

* makes the impulses actually take into account the fucking relays, makes reeling cancel if the rope's already too short, and tweaks values

* reviews + cleanup + makes ungrapple behavior a bit more consistent
joint removal was removed from ungrapple because it mispredicts either way, and breaks grappling hooks attached to the grappling gun (always good to leave possibilities like that open)

* adds a hack to work around grids not caring about waking cross-grid joints

* makes use of dirtyfield(), defenestrates magic number

* y'know it'd probably be better if we were like actually awake before we made commits

* null-coalesce instead of if statement

* two changes

* dont datafield and fix up for sound overrides

---------

Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
Content.Client/Weapons/Misc/GrapplingGunSystem.cs
Content.Shared/Teleportation/Systems/SharedPortalSystem.cs
Content.Shared/Weapons/Misc/SharedGrapplingGunSystem.cs
Content.Shared/Weapons/Ranged/Components/GrapplingGunComponent.cs

index df20042b4be9f1eca69e4c49b9d70c0f260fd314..082dff999f96e18fa37a62a6a2f5c131df1bf050 100644 (file)
@@ -32,16 +32,6 @@ public sealed class GrapplingGunSystem : SharedGrapplingGunSystem
         if (!TryComp<GrapplingGunComponent>(handUid, out var grappling))
             return;
 
-        if (!TryComp<JointComponent>(handUid, out var jointComp) ||
-            !jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint) ||
-            joint is not DistanceJoint distance)
-        {
-            return;
-        }
-
-        if (distance.MaxLength <= distance.MinLength)
-            return;
-
         var reelKey = _input.CmdStates.GetState(EngineKeyFunctions.UseSecondary) == BoundKeyState.Down;
 
         if (!TryComp<CombatModeComponent>(local, out var combatMode) ||
index 3ab703704a4dd232d813520093c862175f41b32a..473a7f9fb9dbb66ba83fbf819232a683bda9d2ff 100644 (file)
@@ -5,12 +5,14 @@ using Content.Shared.Movement.Pulling.Systems;
 using Content.Shared.Popups;
 using Content.Shared.Projectiles;
 using Content.Shared.Teleportation.Components;
+using Content.Shared.Weapons.Misc;
 using Content.Shared.Verbs;
 using Robust.Shared.Audio.Systems;
 using Robust.Shared.Map;
 using Robust.Shared.Network;
 using Robust.Shared.Physics.Dynamics;
 using Robust.Shared.Physics.Events;
+using Robust.Shared.Physics.Systems;
 using Robust.Shared.Player;
 using Robust.Shared.Random;
 using Robust.Shared.Utility;
@@ -31,6 +33,8 @@ public abstract class SharedPortalSystem : EntitySystem
     [Dependency] private readonly SharedTransformSystem _transform = default!;
     [Dependency] private readonly PullingSystem _pulling = default!;
     [Dependency] private readonly SharedPopupSystem _popup = default!;
+    [Dependency] private readonly SharedGrapplingGunSystem _grappling = default!;
+    [Dependency] private readonly SharedJointSystem _joints = default!;
 
     private const string PortalFixture = "portalFixture";
     private const string ProjectileFixture = "projectile";
@@ -105,6 +109,9 @@ public abstract class SharedPortalSystem : EntitySystem
             _pulling.TryStopPull(pullerComp.Pulling.Value, subjectPulling);
         }
 
+        // also break grapple joints
+        _joints.RemoveJoint(subject, SharedGrapplingGunSystem.GrapplingJoint);
+
         // if they came from another portal, just return and wait for them to exit the portal
         if (HasComp<PortalTimeoutComponent>(subject))
         {
index 47e726b0e73ba2cd0d0e6fd27c1b45a8c707847b..b0afbfd2caa0f9674cce9641ba6f2ae1ba1a9464 100644 (file)
@@ -9,9 +9,11 @@ using Content.Shared.Projectiles;
 using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Systems;
 using Robust.Shared.Audio.Systems;
+using Robust.Shared.Containers;
 using Robust.Shared.Network;
 using Robust.Shared.Physics;
 using Robust.Shared.Physics.Components;
+using Robust.Shared.Physics.Controllers;
 using Robust.Shared.Physics.Dynamics.Joints;
 using Robust.Shared.Physics.Systems;
 using Robust.Shared.Serialization;
@@ -19,9 +21,10 @@ using Robust.Shared.Timing;
 
 namespace Content.Shared.Weapons.Misc;
 
-public abstract class SharedGrapplingGunSystem : EntitySystem
+public abstract class SharedGrapplingGunSystem : VirtualController
 {
     [Dependency] protected readonly IGameTiming Timing = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
     [Dependency] private readonly INetManager _netManager = default!;
     [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
     [Dependency] private readonly SharedAudioSystem _audio = default!;
@@ -29,12 +32,13 @@ public abstract class SharedGrapplingGunSystem : EntitySystem
     [Dependency] private readonly SharedJointSystem _joints = default!;
     [Dependency] private readonly SharedGunSystem _gun = default!;
     [Dependency] private readonly SharedPhysicsSystem _physics = default!;
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+    [Dependency] private readonly SharedContainerSystem _container = default!;
 
     public const string GrapplingJoint = "grappling";
 
     public override void Initialize()
     {
-        base.Initialize();
         SubscribeLocalEvent<GrapplingProjectileComponent, ProjectileEmbedEvent>(OnGrappleCollide);
         SubscribeLocalEvent<GrapplingProjectileComponent, JointRemovedEvent>(OnGrappleJointRemoved);
         SubscribeLocalEvent<CanWeightlessMoveEvent>(OnWeightlessMove);
@@ -44,6 +48,9 @@ public abstract class SharedGrapplingGunSystem : EntitySystem
         SubscribeLocalEvent<GrapplingGunComponent, GunShotEvent>(OnGrapplingShot);
         SubscribeLocalEvent<GrapplingGunComponent, ActivateInWorldEvent>(OnGunActivate);
         SubscribeLocalEvent<GrapplingGunComponent, HandDeselectedEvent>(OnGrapplingDeselected);
+
+        UpdatesBefore.Add(typeof(SharedJointSystem)); // We want to run before joints are solved
+        base.Initialize();
     }
 
     private void OnGrappleJointRemoved(EntityUid uid, GrapplingProjectileComponent component, JointRemovedEvent args)
@@ -62,7 +69,7 @@ public abstract class SharedGrapplingGunSystem : EntitySystem
             //todo: this doesn't actually support multigrapple
             // At least show the visuals.
             component.Projectile = shotUid.Value;
-            Dirty(uid, component);
+            DirtyField(uid, component, nameof(GrapplingGunComponent.Projectile));
             var visuals = EnsureComp<JointVisualsComponent>(shotUid.Value);
             visuals.Sprite = component.RopeSprite;
             visuals.Target = uid;
@@ -71,7 +78,6 @@ public abstract class SharedGrapplingGunSystem : EntitySystem
 
         TryComp<AppearanceComponent>(uid, out var appearance);
         _appearance.SetData(uid, SharedTetherGunSystem.TetherVisualsStatus.Key, false, appearance);
-        Dirty(uid, component);
     }
 
     private void OnGrapplingDeselected(EntityUid uid, GrapplingGunComponent component, HandDeselectedEvent args)
@@ -115,91 +121,160 @@ public abstract class SharedGrapplingGunSystem : EntitySystem
         }
     }
 
-    private void OnGunActivate(EntityUid uid, GrapplingGunComponent component, ActivateInWorldEvent args)
+    /// <summary>
+    /// Ungrapples the grappling hook, destroying the hook and severing the joint
+    /// </summary>
+    /// <param name="grapple">Entity for the grappling gun</param>
+    /// <param name="isBreak">Whether to play the sound for the rope breaking</param>
+    /// <param name="user">The user responsible for the ungrapple. Optional</param>
+    public void Ungrapple(Entity<GrapplingGunComponent> grapple, bool isBreak, EntityUid? user = null)
     {
-        if (!Timing.IsFirstTimePredicted || args.Handled || !args.Complex || component.Projectile is not { } projectile)
+        if (!Timing.IsFirstTimePredicted || grapple.Comp.Projectile is not { } projectile)
             return;
 
-        _audio.PlayPredicted(component.CycleSound, uid, args.User);
-        _appearance.SetData(uid, SharedTetherGunSystem.TetherVisualsStatus.Key, true);
+        if(isBreak)
+            _audio.PlayPredicted(grapple.Comp.BreakSound, grapple.Owner, user);
+
+        _appearance.SetData(grapple.Owner, SharedTetherGunSystem.TetherVisualsStatus.Key, true);
 
         if (_netManager.IsServer)
             QueueDel(projectile);
 
-        component.Projectile = null;
-        SetReeling(uid, component, false, args.User);
-        _gun.ChangeBasicEntityAmmoCount(uid, 1);
+        SetReeling(grapple.Owner, grapple.Comp, false, user);
+        grapple.Comp.Projectile = null;
+        DirtyField(grapple.Owner, grapple.Comp, nameof(GrapplingGunComponent.Projectile));
+        _gun.ChangeBasicEntityAmmoCount(grapple.Owner, 1);
+    }
+
+    private void OnGunActivate(EntityUid uid, GrapplingGunComponent component, ActivateInWorldEvent args)
+    {
+        if (!Timing.IsFirstTimePredicted || args.Handled || !args.Complex)
+            return;
+
+        _audio.PlayPredicted(component.CycleSound, uid, args.User);
+        Ungrapple((uid, component), false, args.User);
 
         args.Handled = true;
     }
 
     private void SetReeling(EntityUid uid, GrapplingGunComponent component, bool value, EntityUid? user)
     {
+        if (TryComp<JointComponent>(uid, out var jointComp) &&
+            jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint) &&
+            joint is DistanceJoint distance)
+        {
+            if (distance.MaxLength <= distance.MinLength + component.RopeFullyReeledMargin)
+                value = false;
+        }
+
         if (component.Reeling == value)
             return;
 
         if (value)
         {
-            if (Timing.IsFirstTimePredicted)
-                component.Stream = _audio.PlayPredicted(component.ReelSound, uid, user)?.Entity;
+            // We null-coalesce here because playing the sound again will cause it to become eternally stuck playing
+            component.Stream = _audio.PlayPredicted(component.ReelSound, uid, user)?.Entity ?? component.Stream;
         }
-        else
+        else if (!value && component.Stream.HasValue)
         {
-            if (Timing.IsFirstTimePredicted)
-            {
-                component.Stream = _audio.Stop(component.Stream);
-            }
+            component.Stream = _audio.Stop(component.Stream);
         }
 
         component.Reeling = value;
-        Dirty(uid, component);
+
+        DirtyField(uid, component, nameof(GrapplingGunComponent.Reeling));
     }
 
-    public override void Update(float frameTime)
+    public override void UpdateBeforeSolve(bool prediction, float frameTime)
     {
-        base.Update(frameTime);
+        base.UpdateBeforeSolve(prediction, frameTime);
 
-        var query = EntityQueryEnumerator<GrapplingGunComponent>();
+        var query = EntityQueryEnumerator<GrapplingGunComponent, JointComponent>();
 
-        while (query.MoveNext(out var uid, out var grappling))
+        while (query.MoveNext(out var uid, out var grappling, out var jointComp))
         {
-            if (!grappling.Reeling)
+            if (!jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint) ||
+                joint is not DistanceJoint distance ||
+                !_entities.TryGetComponent<JointComponent>(joint.BodyAUid, out var hookJointComp))
             {
-                if (Timing.IsFirstTimePredicted)
-                {
-                    // Just in case.
-                    grappling.Stream = _audio.Stop(grappling.Stream);
-                }
-
+                if (_netManager.IsServer) // Client might not receive the joint due to PVS culling, so lets not spam them with 23895739 mispredicted ungrapples
+                    Ungrapple((uid, grappling), true);
                 continue;
             }
 
-            if (!TryComp<JointComponent>(uid, out var jointComp) ||
-                !jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint) ||
-                joint is not DistanceJoint distance)
+            // If the joint breaks, it gets disabled
+            if (distance.Enabled == false)
             {
-                SetReeling(uid, grappling, false, null);
+                Ungrapple((uid, grappling), true);
                 continue;
             }
 
-            // TODO: This should be on engine.
-            distance.MaxLength = MathF.Max(distance.MinLength, distance.MaxLength - grappling.ReelRate * frameTime);
-            distance.Length = MathF.Min(distance.MaxLength, distance.Length);
+            var physicalGrapple = jointComp.Relay.HasValue ? jointComp.Relay.Value : joint.BodyBUid;
+            var physicalHook = hookJointComp.Relay.HasValue ? hookJointComp.Relay.Value : joint.BodyAUid;
+
+            // HACK: preventing both ends of the grappling hook from sleeping if neither are on the same grid, so that grid movement works as expected
+            if (_transform.GetGrid(physicalHook) != _transform.GetGrid(physicalGrapple))
+            {
+                _physics.WakeBody(physicalHook);
+                _physics.WakeBody(physicalGrapple);
+            }
+            // END OF HACK
 
-            _physics.WakeBody(joint.BodyAUid);
-            _physics.WakeBody(joint.BodyBUid);
+            var bodyAWorldPos = _transform.GetWorldPosition(physicalHook);
+            var bodyBWorldPos = _transform.GetWorldPosition(physicalGrapple);
 
-            if (jointComp.Relay != null)
+            // The solver does not handle setting the rope's length, but we still need to work with a copy of it to prevent jank.
+            var ropeLength = (bodyAWorldPos - bodyBWorldPos).Length();
+
+            // Rope should just break, instantly, if the user is teleported past its max length
+            if (ropeLength >= distance.MaxLength + grappling.RopeMargin)
             {
-                _physics.WakeBody(jointComp.Relay.Value);
+                Ungrapple((uid, grappling), true);
+                continue;
             }
 
-            Dirty(uid, jointComp);
+            if (!grappling.Reeling)
+            {
+                // Just in case.
+                if (grappling.Stream.HasValue && Timing.IsFirstTimePredicted)
+                    grappling.Stream = _audio.Stop(grappling.Stream);
+
+                continue;
+            }
+
+
+            // TODO: Contracting DistanceJoints should be in engine
+            if (distance.MaxLength >= ropeLength + grappling.RopeMargin)
+            {
+                distance.MaxLength = MathF.Max(distance.MinLength + grappling.RopeMargin, distance.MaxLength - grappling.ReelRate * frameTime);
+                distance.MaxLength = MathF.Max(ropeLength + grappling.RopeMargin, distance.MaxLength);
+                ropeLength = MathF.Min(distance.MaxLength, ropeLength);
+
+                distance.Length = ropeLength;
+            }
 
-            if (distance.MaxLength.Equals(distance.MinLength))
+            if (ropeLength <= distance.MinLength + grappling.RopeFullyReeledMargin)
             {
                 SetReeling(uid, grappling, false, null);
             }
+            else if (ropeLength >= distance.MaxLength - grappling.RopeMargin)
+            {
+                var targetDirection = (bodyAWorldPos - bodyBWorldPos).Normalized();
+
+                var grapplerUidA = _container.TryGetOuterContainer(physicalHook, Transform(physicalHook), out var containerA) ? containerA.Owner : physicalHook;
+                var grapplerBodyA = Comp<PhysicsComponent>(grapplerUidA);
+
+                var massFactorA = MathF.Min(grapplerBodyA.InvMass * grappling.ReelMassCoefficient, 1f);
+                _physics.ApplyLinearImpulse(grapplerUidA, targetDirection * grappling.ReelForce * massFactorA * frameTime * -1, body: grapplerBodyA);
+
+                var grapplerUidB = _container.TryGetOuterContainer(physicalGrapple, Transform(physicalGrapple), out var containerB) ? containerB.Owner : physicalGrapple;
+                var grapplerBodyB = Comp<PhysicsComponent>(grapplerUidB);
+
+                var massFactorB = MathF.Min(grapplerBodyB.InvMass * grappling.ReelMassCoefficient, 1f);
+                _physics.ApplyLinearImpulse(grapplerUidB, targetDirection * grappling.ReelForce * massFactorB * frameTime, body: grapplerBodyB);
+            }
+
+            Dirty(uid, jointComp);
         }
     }
 
@@ -224,17 +299,28 @@ public abstract class SharedGrapplingGunSystem : EntitySystem
 
     private void OnGrappleCollide(EntityUid uid, GrapplingProjectileComponent component, ref ProjectileEmbedEvent args)
     {
-        if (!Timing.IsFirstTimePredicted || !args.Weapon.HasValue)
+        if (!Timing.IsFirstTimePredicted || !args.Weapon.HasValue || !_entities.TryGetComponent<GrapplingGunComponent>(args.Weapon, out var grapple))
             return;
 
-        var jointComp = EnsureComp<JointComponent>(uid);
+        var grapplePos = _transform.GetWorldPosition(args.Weapon.Value);
+        var hookPos = _transform.GetWorldPosition(uid);
+        if ((grapplePos - hookPos).Length() >= grapple.RopeMaxLength)
+        {
+            Ungrapple((args.Weapon.Value, grapple), true);
+            return;
+        }
+
         var joint = _joints.CreateDistanceJoint(uid, args.Weapon.Value, id: GrapplingJoint);
-        joint.MaxLength = joint.Length + 0.2f;
-        joint.Stiffness = 1f;
-        joint.MinLength = 1f; // Length of a tile to prevent pulling yourself into / through walls
-        // Setting velocity directly for mob movement fucks this so need to make them aware of it.
-        // joint.Breakpoint = 4000f;
-        Dirty(uid, jointComp);
+        joint.MaxLength = joint.Length + grapple.RopeMargin;
+        joint.Stiffness = grapple.RopeStiffness;
+        joint.MinLength = grapple.RopeMinLength; // Length of a tile to prevent pulling yourself into / through walls
+        joint.Breakpoint = grapple.RopeBreakPoint;
+
+        var jointCompHook = _entities.GetComponent<JointComponent>(uid); // we use get here because if the component doesn't exist then something has fucked up bigtime
+        var jointCompGrapple = _entities.GetComponent<JointComponent>(args.Weapon.Value);
+
+        _joints.SetRelay(uid, args.Embedded, jointCompHook);
+        _joints.RefreshRelay(args.Weapon.Value, jointCompGrapple);
     }
 
     [Serializable, NetSerializable]
index 553f0c10f3216176ff435ce76f1b66caeeb2deac..39d310cd4fe84294069d89d52d9f0986c460d1e0 100644 (file)
@@ -5,36 +5,108 @@ using Robust.Shared.Utility;
 namespace Content.Shared.Weapons.Ranged.Components;
 
 // I have tried to make this as generic as possible but "delete joint on cycle / right-click reels in" is very specific behavior.
-[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true)]
 public sealed partial class GrapplingGunComponent : Component
 {
     /// <summary>
-    /// Hook's reeling force and speed - the higher the number, the faster the hook rewinds.
+    /// Hook's reeling speed when there's no resistance.
     /// </summary>
     [DataField, AutoNetworkedField]
     public float ReelRate = 2.5f;
 
-    [DataField("jointId"), AutoNetworkedField]
-    public string Joint = string.Empty;
+    /// <summary>
+    /// Amount of force to use while reeling. This is made extremely small when compensating for frametime
+    /// Don't be afraid to use large numbers, but do beware that this becomes fast as fuck in frictionless conditions such as space
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float ReelForce = 4000f;
+
+    /// <summary>
+    /// Highest mass that can be reeled in without resistance
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float ReelMassCoefficient = 80f;
+
+    /// <summary>
+    /// Margin between max length and the grappling gun when reeling the grappling hook in.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float RopeMargin = 0.2f;
+
+    /// <summary>
+    /// Margin from the min length for the rope to be considered fully reeled-in, preventing it from being reeled in further
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float RopeFullyReeledMargin = 0.22f;
 
+    /// <summary>
+    /// Minimum length for the grappling hook's rope
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float RopeMinLength = 1f;
+
+    /// <summary>
+    /// Maximum length the grapple can actually be.
+    /// If this is too large, then the rope gets culled out of PVS, causing issues
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float? RopeMaxLength;
+
+    /// <summary>
+    /// Stiffness of the rope, in N/m
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float RopeStiffness = 1f;
+
+    /// <summary>
+    /// Amount of force, in newtons, needed to snap the rope
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public float RopeBreakPoint = 50000f;
+
+    /// <summary>
+    /// Entity UID of the grapple's hook
+    /// </summary>
     [DataField, AutoNetworkedField]
     public EntityUid? Projectile;
 
-    [ViewVariables(VVAccess.ReadWrite), DataField("reeling"), AutoNetworkedField]
+    /// <summary>
+    /// Whether or not the grappling gun is currently reeling in
+    /// </summary>
+    [DataField, AutoNetworkedField]
     public bool Reeling;
 
-    [ViewVariables(VVAccess.ReadWrite), DataField("reelSound"), AutoNetworkedField]
+    /// <summary>
+    /// Looping sound used while the grappling gun is reeling
+    /// </summary>
+    [DataField, AutoNetworkedField]
     public SoundSpecifier? ReelSound = new SoundPathSpecifier("/Audio/Weapons/reel.ogg")
     {
         Params = AudioParams.Default.WithLoop(true)
     };
 
-    [ViewVariables(VVAccess.ReadWrite), DataField("cycleSound"), AutoNetworkedField]
+    /// <summary>
+    /// Sound that plays when the user cycles the grappling gun by using it in their hand
+    /// </summary>
+    [DataField, AutoNetworkedField]
     public SoundSpecifier? CycleSound = new SoundPathSpecifier("/Audio/Weapons/Guns/MagIn/kinetic_reload.ogg");
 
-    [DataField, ViewVariables]
+    /// <summary>
+    /// Sound that plays when the rope breaks due to physics
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public SoundSpecifier? BreakSound = new SoundPathSpecifier("/Audio/Items/snap.ogg");
+
+    /// <summary>
+    /// Sprite specifier for the rope, used to visualize the joint
+    /// </summary>
+    [DataField, AutoNetworkedField]
     public SpriteSpecifier RopeSprite =
         new SpriteSpecifier.Rsi(new ResPath("Objects/Weapons/Guns/Launchers/grappling_gun.rsi"), "rope");
 
+    /// <summary>
+    /// Entity UID for the audio stream, which plays <see cref="ReelSound"/>.
+    /// </summary>
+    [ViewVariables]
     public EntityUid? Stream;
 }