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;
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!;
[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);
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)
//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;
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)
}
}
- 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);
}
}
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]
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;
}