mirror of
https://github.com/space-syndicate/space-station-14.git
synced 2026-10-09 10:21:18 +03:00
467 lines
19 KiB
C#
467 lines
19 KiB
C#
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!;
|
|
|
|
/// <summary>
|
|
/// Name of the joint between a grappling gun and its hook.
|
|
/// </summary>
|
|
public const string GrapplingJoint = "grappling";
|
|
|
|
/// <summary>
|
|
/// The "default" mass below which pulling strength is scaled, to prevent small entities from being yeeted too fast.
|
|
/// </summary>
|
|
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<GrapplingProjectileComponent> entity, ref JointRemovedEvent args)
|
|
{
|
|
if (_netManager.IsServer)
|
|
QueueDel(entity);
|
|
}
|
|
|
|
[SubscribeLocalEvent]
|
|
private void OnGrappleProjectileShutdown(Entity<GrapplingProjectileComponent> ent, ref ComponentShutdown args)
|
|
{
|
|
if (!TryComp<EmbeddableProjectileComponent>(ent, out var embedComp) || embedComp.EmbeddedIntoUid == null)
|
|
return;
|
|
|
|
if (!TryComp<GrapplingProjectileEmbedComponent>(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<GrapplingGunComponent>(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<GrapplingProjectileEmbedComponent>(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<JointComponent>(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<GrapplingGunComponent> entity, ref GunShotEvent args)
|
|
{
|
|
foreach (var (shotUid, _) in args.Ammo)
|
|
{
|
|
if (!HasComp<GrapplingProjectileComponent>(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<JointVisualsComponent>(shotUid.Value);
|
|
visuals.Sprite = entity.Comp.RopeSprite;
|
|
visuals.Target = entity.Owner;
|
|
Dirty(shotUid.Value, visuals);
|
|
}
|
|
|
|
if (TryComp<AppearanceComponent>(entity.Owner, out var appearance))
|
|
_appearance.SetData(entity.Owner, SharedTetherGunSystem.TetherVisualsStatus.Key, false, appearance);
|
|
}
|
|
|
|
[SubscribeLocalEvent]
|
|
private void OnGunActivate(Entity<GrapplingGunComponent> 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<GrapplingGunComponent> 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<GrapplingGunComponent>(activeItem, out var grappling))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (msg.Reeling &&
|
|
(!TryComp<CombatModeComponent>(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<JointRelayTargetComponent>(ev.Uid, out var relayComp))
|
|
return;
|
|
|
|
foreach (var relay in relayComp.Relayed)
|
|
{
|
|
if (TryComp<JointComponent>(relay, out var jointRelay) && jointRelay.GetJoints.ContainsKey(GrapplingJoint))
|
|
{
|
|
ev.CanMove = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
[SubscribeLocalEvent]
|
|
private void OnAnchorStateChanged(Entity<GrapplingProjectileEmbedComponent> entity, ref AnchorStateChangedEvent args)
|
|
{
|
|
foreach (var hook in entity.Comp.GrapplingProjectiles)
|
|
{
|
|
if (!TryComp<ProjectileComponent>(hook, out var projectileComp) || !TryComp<JointComponent>(hook, out var jointComp))
|
|
continue;
|
|
|
|
if (projectileComp.Weapon == null || !TryComp<GrapplingGunComponent>(projectileComp.Weapon, out var gunComp))
|
|
continue;
|
|
|
|
RefreshJointRelay(entity);
|
|
}
|
|
}
|
|
|
|
public override void UpdateBeforeSolve(bool prediction, float frameTime)
|
|
{
|
|
base.UpdateBeforeSolve(prediction, frameTime);
|
|
|
|
var query = EntityQueryEnumerator<GrapplingGunComponent, JointComponent>();
|
|
|
|
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<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(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<PhysicsComponent>(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<PhysicsComponent>(grapplerUidA, out var grapplerBodyA) ||
|
|
!TryComp<PhysicsComponent>(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 && 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);
|
|
}
|
|
}
|
|
|
|
/// <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 (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<GrapplingGunComponent> entity, bool value, EntityUid? user)
|
|
{
|
|
if (TryComp<JointComponent>(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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether the entity is hooked to something via grappling gun.
|
|
/// </summary>
|
|
/// <param name="entity">Entity to check.</param>
|
|
/// <returns>True if hooked, false otherwise.</returns>
|
|
public bool IsEntityHooked(Entity<JointRelayTargetComponent?> entity)
|
|
{
|
|
if (!Resolve(entity, ref entity.Comp, false))
|
|
return false;
|
|
|
|
foreach (var uid in entity.Comp.Relayed)
|
|
{
|
|
if (HasComp<GrapplingGunComponent>(uid))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the relay of any grappling hook to ensure it uses either the embedded entity, or the grid if the entity is anchored.
|
|
/// </summary>
|
|
private void RefreshJointRelay(Entity<GrapplingProjectileEmbedComponent> entity)
|
|
{
|
|
foreach (var hook in entity.Comp.GrapplingProjectiles)
|
|
{
|
|
if (!HasComp<GrapplingProjectileComponent>(hook) || !TryComp<JointComponent>(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<PhysicsComponent>(grid))
|
|
{
|
|
joint.LocalAnchorA = _transform.GetRelativePosition(Transform(hook), grid);
|
|
_joints.SetRelay(hook, grid);
|
|
}
|
|
else
|
|
{
|
|
joint.LocalAnchorA = Vector2.Zero;
|
|
_joints.SetRelay(hook, entity.Owner, jointComp);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client to server message to begin/end reeling in the grapplign hook.
|
|
/// </summary>
|
|
[Serializable, NetSerializable]
|
|
protected sealed class RequestGrapplingReelMessage : EntityEventArgs
|
|
{
|
|
/// <summary>
|
|
/// Whether the client wants to reel in or stop reeling.
|
|
/// </summary>
|
|
public bool Reeling;
|
|
|
|
public RequestGrapplingReelMessage(bool reeling)
|
|
{
|
|
Reeling = reeling;
|
|
}
|
|
}
|
|
}
|