Grappling rework - Grappling hooks are now physics-driven (#42409)

* 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>
This commit is contained in:
deathride58 2026-01-22 17:15:01 -05:00 committed by Coryler
parent a5149c759e
commit e5f734769f
4 changed files with 227 additions and 72 deletions

View 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) ||

View 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))
{

View 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 (!jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint) ||
joint is not DistanceJoint distance ||
!_entities.TryGetComponent<JointComponent>(joint.BodyAUid, out var hookJointComp))
{
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 the joint breaks, it gets disabled
if (distance.Enabled == false)
{
Ungrapple((uid, grappling), true);
continue;
}
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
var bodyAWorldPos = _transform.GetWorldPosition(physicalHook);
var bodyBWorldPos = _transform.GetWorldPosition(physicalGrapple);
// 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)
{
Ungrapple((uid, grappling), true);
continue;
}
if (!grappling.Reeling)
{
if (Timing.IsFirstTimePredicted)
{
// Just in case.
// Just in case.
if (grappling.Stream.HasValue && Timing.IsFirstTimePredicted)
grappling.Stream = _audio.Stop(grappling.Stream);
}
continue;
}
if (!TryComp<JointComponent>(uid, out var jointComp) ||
!jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint) ||
joint is not DistanceJoint distance)
// 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 (ropeLength <= distance.MinLength + grappling.RopeFullyReeledMargin)
{
SetReeling(uid, grappling, false, null);
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);
_physics.WakeBody(joint.BodyAUid);
_physics.WakeBody(joint.BodyBUid);
if (jointComp.Relay != null)
else if (ropeLength >= distance.MaxLength - grappling.RopeMargin)
{
_physics.WakeBody(jointComp.Relay.Value);
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);
if (distance.MaxLength.Equals(distance.MinLength))
{
SetReeling(uid, grappling, false, null);
}
}
}
@ -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]

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