using System.Numerics; using Content.Shared.CombatMode; using Content.Shared.Gravity; using Content.Shared.Hands; using Content.Shared.Hands.EntitySystems; using Content.Shared.Interaction; using Content.Shared.Movement.Events; using Content.Shared.Physics; 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; using Robust.Shared.Timing; namespace Content.Shared.Weapons.Misc; public abstract partial class SharedGrapplingGunSystem : VirtualController { [Dependency] protected IGameTiming Timing = default!; [Dependency] private INetManager _netManager = default!; [Dependency] private SharedAppearanceSystem _appearance = default!; [Dependency] private SharedAudioSystem _audio = default!; [Dependency] private SharedHandsSystem _hands = default!; [Dependency] private SharedJointSystem _joints = default!; [Dependency] private SharedGunSystem _gun = default!; [Dependency] private SharedPhysicsSystem _physics = default!; [Dependency] private SharedTransformSystem _transform = default!; [Dependency] private SharedGravitySystem _gravity = default!; [Dependency] private SharedContainerSystem _container = default!; /// /// Name of the joint between a grappling gun and its hook. /// public const string GrapplingJoint = "grappling"; /// /// The "default" mass below which pulling strength is scaled, to prevent small entities from being yeeted too fast. /// public const float BaseWeightMass = 80f; public override void Initialize() { base.Initialize(); // TODO: After step trigger refactor, dropping a grappling gun should manually try and activate step triggers it's suppressing. UpdatesBefore.Add(typeof(SharedJointSystem)); // We want to run before joints are solved } [SubscribeLocalEvent] private void OnGrappleJointRemoved(Entity entity, ref JointRemovedEvent args) { if (_netManager.IsServer) QueueDel(entity); } [SubscribeLocalEvent] private void OnGrappleProjectileShutdown(Entity ent, ref ComponentShutdown args) { if (!TryComp(ent, out var embedComp) || embedComp.EmbeddedIntoUid == null) return; if (!TryComp(embedComp.EmbeddedIntoUid, out var grapplingEmbedComp)) return; grapplingEmbedComp.GrapplingProjectiles.Remove(ent); } [SubscribeLocalEvent] private void OnGrappleCollide(EntityUid uid, GrapplingProjectileComponent component, ref ProjectileEmbedEvent args) { if (!args.Weapon.HasValue || !TryComp(args.Weapon, out var grapple)) return; 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 embedComp = EnsureComp(args.Embedded); embedComp.GrapplingProjectiles.Add(uid); var joint = _joints.CreateDistanceJoint(uid, args.Weapon.Value, id: GrapplingJoint); 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 jointCompGrapple = Comp(args.Weapon.Value); // Since the grappling hook is offset from the grid, we need to update the local anchor so that the relay correctly uses the correct anchor for the grid. RefreshJointRelay((args.Embedded, embedComp)); _joints.RefreshRelay(args.Weapon.Value, jointCompGrapple); } [SubscribeLocalEvent] private void OnGrapplingShot(Entity entity, ref GunShotEvent args) { foreach (var (shotUid, _) in args.Ammo) { if (!HasComp(shotUid)) continue; //todo: this doesn't actually support multigrapple // At least show the visuals. entity.Comp.Projectile = shotUid.Value; DirtyField(entity.AsNullable(), nameof(GrapplingGunComponent.Projectile)); var visuals = EnsureComp(shotUid.Value); visuals.Sprite = entity.Comp.RopeSprite; visuals.Target = entity.Owner; Dirty(shotUid.Value, visuals); } if (TryComp(entity.Owner, out var appearance)) _appearance.SetData(entity.Owner, SharedTetherGunSystem.TetherVisualsStatus.Key, false, appearance); } [SubscribeLocalEvent] private void OnGunActivate(Entity entity, ref ActivateInWorldEvent args) { if (args.Handled || !args.Complex) return; _audio.PlayPredicted(entity.Comp.CycleSound, entity.Owner, args.User); Ungrapple((entity), false, args.User); args.Handled = true; } [SubscribeLocalEvent] private void OnGrapplingDeselected(Entity entity, ref HandDeselectedEvent args) { SetReeling(entity, false, args.User); } [EventSubscription] private void OnGrapplingReel(RequestGrapplingReelMessage msg, EntitySessionEventArgs args) { if (args.SenderSession.AttachedEntity is not { } player) return; if (!_hands.TryGetActiveItem(player, out var activeItem) || !TryComp(activeItem, out var grappling)) { return; } if (msg.Reeling && (!TryComp(player, out var combatMode) || !combatMode.IsInCombatMode)) { return; } SetReeling((activeItem.Value, grappling), msg.Reeling, player); } [SubscribeLocalEvent] private void OnWeightlessMove(ref CanWeightlessMoveEvent ev) { if (ev.CanMove || !TryComp(ev.Uid, out var relayComp)) return; foreach (var relay in relayComp.Relayed) { if (TryComp(relay, out var jointRelay) && jointRelay.GetJoints.ContainsKey(GrapplingJoint)) { ev.CanMove = true; return; } } } [SubscribeLocalEvent] private void OnAnchorStateChanged(Entity entity, ref AnchorStateChangedEvent args) { foreach (var hook in entity.Comp.GrapplingProjectiles) { if (!TryComp(hook, out var projectileComp) || !TryComp(hook, out var jointComp)) continue; if (projectileComp.Weapon == null || !TryComp(projectileComp.Weapon, out var gunComp)) continue; RefreshJointRelay(entity); } } public override void UpdateBeforeSolve(bool prediction, float frameTime) { base.UpdateBeforeSolve(prediction, frameTime); var query = EntityQueryEnumerator(); 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 || !TryComp(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(joint.BodyAUid); var bodyBWorldPos = _transform.GetWorldPosition(joint.BodyBUid); // 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) { // 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 (ropeLength <= distance.MinLength + grappling.RopeFullyReeledMargin) { SetReeling((uid, grappling), false, null); } else if (ropeLength >= distance.MaxLength - grappling.RopeMargin) { // Checks if the entity is "tied" to the grid it is on via extra-gravity technology (e.g. magboots). If so, for the purposes of reeling it counts as if you're weighing the same as the grid. bool attachedToGrid; // If the entities being targetted are on the same grid. If they are, the grid itself should not be affected with any pushing, to avoid becoming a "grappling hook-powered shuttle". var sameGrid = false; if (_transform.GetGrid(joint.BodyAUid) == _transform.GetGrid(joint.BodyBUid)) { attachedToGrid = false; sameGrid = true; } else { if (jointComp.Relay != null) { _physics.WakeBody(jointComp.Relay.Value); attachedToGrid = Transform(jointComp.Relay.Value).Anchored || !_gravity.IsWeightless(jointComp.Relay.Value) && !_gravity.IsWeightlessStatusFromGrid(jointComp.Relay.Value); } else { attachedToGrid = Transform(joint.BodyAUid).Anchored || !_gravity.IsWeightless(joint.BodyAUid) && !_gravity.IsWeightlessStatusFromGrid(joint.BodyAUid); } } var targetDirection = (bodyAWorldPos - bodyBWorldPos).Normalized(); var grapplerUidA = _container.TryGetOuterContainer(physicalHook, Transform(physicalHook), out var containerA) ? containerA.Owner : physicalHook; var grapplerOffsetA = _transform.GetRelativePosition(Transform(joint.BodyAUid), grapplerUidA); var grapplerUidB = _container.TryGetOuterContainer(physicalGrapple, Transform(physicalGrapple), out var containerB) ? containerB.Owner : physicalGrapple; // Don't try to push grids without physics (e.g. Planets). if (attachedToGrid && _transform.GetGrid(joint.BodyBUid) is { } gridB && HasComp(gridB)) grapplerUidB = gridB; var grapplerOffsetB = _transform.GetRelativePosition(Transform(joint.BodyBUid), grapplerUidB); // Either end may lack physics in which case there's nothing to apply an impulse to. if (!TryComp(grapplerUidA, out var grapplerBodyA) || !TryComp(grapplerUidB, out var grapplerBodyB)) { Dirty(uid, jointComp); continue; } // Note that this way of calculating the impulse does not take into account objects being stuck on things, e.g. a movable grapple point stuck behind a wall. // Ideally the contraction of the joint itself should take this into account, but alas, this works for now. var massFactorA = 1f; var massFactorB = 1f; // To prevent small things go zoomies // Technically doesn't preserve momentum but it's either this or things start being yeeted at light speed. if (grapplerBodyA.Mass < BaseWeightMass) massFactorA *= grapplerBodyA.Mass / BaseWeightMass; if (grapplerBodyB.Mass < BaseWeightMass) massFactorB *= grapplerBodyB.Mass / BaseWeightMass; if (!sameGrid || sameGrid && physicalHook != _transform.GetGrid(joint.BodyAUid)) _physics.ApplyLinearImpulse(grapplerUidA, -targetDirection * massFactorA * grappling.ReelForce * frameTime, grapplerOffsetA, body: grapplerBodyA); _physics.ApplyLinearImpulse(grapplerUidB, targetDirection * massFactorB * grappling.ReelForce * frameTime, grapplerOffsetB, body: grapplerBodyB); } Dirty(uid, jointComp); } } /// /// Ungrapples the grappling hook, destroying the hook and severing the joint /// /// Entity for the grappling gun /// Whether to play the sound for the rope breaking /// The user responsible for the ungrapple. Optional public void Ungrapple(Entity grapple, bool isBreak, EntityUid? user = null) { if (grapple.Comp.Projectile is not { } projectile) return; if (isBreak && Timing.IsFirstTimePredicted) { if (user != null) _audio.PlayPredicted(grapple.Comp.BreakSound, grapple.Owner, user); else if (_netManager.IsServer) // This feels... hacky. _audio.PlayPvs(grapple.Comp.BreakSound, grapple.Owner); } _appearance.SetData(grapple.Owner, SharedTetherGunSystem.TetherVisualsStatus.Key, true); if (_netManager.IsServer) QueueDel(projectile); SetReeling(grapple, false, user); grapple.Comp.Projectile = null; DirtyField(grapple.Owner, grapple.Comp, nameof(GrapplingGunComponent.Projectile)); _gun.ChangeBasicEntityAmmoCount(grapple.Owner, 1); } private void SetReeling(Entity entity, bool value, EntityUid? user) { if (TryComp(entity.Owner, out var jointComp) && jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint) && joint is DistanceJoint distance) { if (distance.MaxLength <= distance.MinLength + entity.Comp.RopeFullyReeledMargin) value = false; } if (entity.Comp.Reeling == value) return; if (value) { // We null-coalesce here because playing the sound again will cause it to become eternally stuck playing entity.Comp.Stream ??= _audio.PlayPredicted(entity.Comp.ReelSound, entity.Owner, user)?.Entity; } else if (!value && entity.Comp.Stream.HasValue && Timing.IsFirstTimePredicted) { // The IsFirstTimePredicted check is important here because otherwise component.Stream will be set to null from an early cancellation if this isn't FirstTimePredicted entity.Comp.Stream = _audio.Stop(entity.Comp.Stream); } entity.Comp.Reeling = value; DirtyField(entity.AsNullable(), nameof(GrapplingGunComponent.Reeling)); } /// /// Checks whether the entity is hooked to something via grappling gun. /// /// Entity to check. /// True if hooked, false otherwise. public bool IsEntityHooked(Entity entity) { if (!Resolve(entity, ref entity.Comp, false)) return false; foreach (var uid in entity.Comp.Relayed) { if (HasComp(uid)) return true; } return false; } /// /// Updates the relay of any grappling hook to ensure it uses either the embedded entity, or the grid if the entity is anchored. /// private void RefreshJointRelay(Entity entity) { foreach (var hook in entity.Comp.GrapplingProjectiles) { if (!HasComp(hook) || !TryComp(hook, out var jointComp)) continue; if (!jointComp.GetJoints.TryGetValue(GrapplingJoint, out var joint)) continue; // Grids without physics (e.g. planets) can't be used as a relay body, so relay to the entity itself instead. if (Transform(entity).Anchored && _transform.GetGrid(entity.Owner) is { } grid && HasComp(grid)) { joint.LocalAnchorA = _transform.GetRelativePosition(Transform(hook), grid); _joints.SetRelay(hook, grid); } else { joint.LocalAnchorA = Vector2.Zero; _joints.SetRelay(hook, entity.Owner, jointComp); } } } /// /// Client to server message to begin/end reeling in the grapplign hook. /// [Serializable, NetSerializable] protected sealed class RequestGrapplingReelMessage : EntityEventArgs { /// /// Whether the client wants to reel in or stop reeling. /// public bool Reeling; public RequestGrapplingReelMessage(bool reeling) { Reeling = reeling; } } }