diff --git a/Content.Server/Movement/Components/PullMoverComponent.cs b/Content.Server/Movement/Components/PullMoverComponent.cs
new file mode 100644
index 0000000000..19a01c6b17
--- /dev/null
+++ b/Content.Server/Movement/Components/PullMoverComponent.cs
@@ -0,0 +1,13 @@
+namespace Content.Server.Movement.Components;
+
+///
+/// Added to an entity that is ctrl-click moving their pulled object.
+///
+///
+/// This just exists so we don't have MoveEvent subs going off for every single mob constantly.
+///
+[RegisterComponent]
+public sealed partial class PullMoverComponent : Component
+{
+
+}
diff --git a/Content.Server/Movement/Components/PullMovingComponent.cs b/Content.Server/Movement/Components/PullMovingComponent.cs
new file mode 100644
index 0000000000..32c50d657a
--- /dev/null
+++ b/Content.Server/Movement/Components/PullMovingComponent.cs
@@ -0,0 +1,14 @@
+using Robust.Shared.Map;
+
+namespace Content.Server.Movement.Components;
+
+///
+/// Added when an entity is being ctrl-click moved when pulled.
+///
+[RegisterComponent]
+public sealed partial class PullMovingComponent : Component
+{
+ // Not serialized to indicate THIS CODE SUCKS, fix pullcontroller first
+ [ViewVariables]
+ public EntityCoordinates MovingTo;
+}
diff --git a/Content.Server/Movement/Systems/PullController.cs b/Content.Server/Movement/Systems/PullController.cs
new file mode 100644
index 0000000000..72110ff67d
--- /dev/null
+++ b/Content.Server/Movement/Systems/PullController.cs
@@ -0,0 +1,318 @@
+using System.Numerics;
+using Content.Server.Movement.Components;
+using Content.Server.Physics.Controllers;
+using Content.Shared.ActionBlocker;
+using Content.Shared.Gravity;
+using Content.Shared.Input;
+using Content.Shared.Movement.Pulling.Components;
+using Content.Shared.Movement.Pulling.Events;
+using Content.Shared.Movement.Pulling.Systems;
+using Content.Shared.Rotatable;
+using Robust.Server.Physics;
+using Robust.Shared.Containers;
+using Robust.Shared.Input.Binding;
+using Robust.Shared.Map;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Physics.Controllers;
+using Robust.Shared.Physics.Dynamics.Joints;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Movement.Systems;
+
+public sealed class PullController : VirtualController
+{
+ /*
+ * This code is awful. If you try to tweak this without refactoring it I'm gonna revert it.
+ */
+
+ // Parameterization for pulling:
+ // Speeds. Note that the speed is mass-independent (multiplied by mass).
+ // Instead, tuning to mass is done via the mass values below.
+ // Note that setting the speed too high results in overshoots (stabilized by drag, but bad)
+ private const float AccelModifierHigh = 15f;
+ private const float AccelModifierLow = 60.0f;
+ // High/low-mass marks. Curve is constant-lerp-constant, i.e. if you can even pull an item,
+ // you'll always get at least AccelModifierLow and no more than AccelModifierHigh.
+ private const float AccelModifierHighMass = 70.0f; // roundstart saltern emergency closet
+ private const float AccelModifierLowMass = 5.0f; // roundstart saltern emergency crowbar
+ // Used to control settling (turns off pulling).
+ private const float MaximumSettleVelocity = 0.1f;
+ private const float MaximumSettleDistance = 0.1f;
+ // Settle shutdown control.
+ // Mustn't be too massive, as that causes severe mispredicts *and can prevent it ever resolving*.
+ // Exists to bleed off "I pulled my crowbar" overshoots.
+ // Minimum velocity for shutdown to be necessary. This prevents stuff getting stuck b/c too much shutdown.
+ private const float SettleMinimumShutdownVelocity = 0.25f;
+ // Distance in which settle shutdown multiplier is at 0. It then scales upwards linearly with closer distances.
+ private const float SettleShutdownDistance = 1.0f;
+ // Velocity change of -LinearVelocity * frameTime * this
+ private const float SettleShutdownMultiplier = 20.0f;
+
+ // How much you must move for the puller movement check to actually hit.
+ private const float MinimumMovementDistance = 0.005f;
+
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
+ [Dependency] private readonly SharedContainerSystem _container = default!;
+ [Dependency] private readonly SharedGravitySystem _gravity = default!;
+
+ ///
+ /// If distance between puller and pulled entity lower that this threshold,
+ /// pulled entity will not change its rotation.
+ /// Helps with small distance jittering
+ ///
+ private const float ThresholdRotDistance = 1;
+
+ ///
+ /// If difference between puller and pulled angle lower that this threshold,
+ /// pulled entity will not change its rotation.
+ /// Helps with diagonal movement jittering
+ /// As of further adjustments, should divide cleanly into 90 degrees
+ ///
+ private const float ThresholdRotAngle = 22.5f;
+
+ private EntityQuery _physicsQuery;
+ private EntityQuery _pullableQuery;
+ private EntityQuery _pullerQuery;
+ private EntityQuery _xformQuery;
+
+ public override void Initialize()
+ {
+ CommandBinds.Builder
+ .Bind(ContentKeyFunctions.MovePulledObject, new PointerInputCmdHandler(OnRequestMovePulledObject))
+ .Register();
+
+ _physicsQuery = GetEntityQuery();
+ _pullableQuery = GetEntityQuery();
+ _pullerQuery = GetEntityQuery();
+ _xformQuery = GetEntityQuery();
+
+ UpdatesAfter.Add(typeof(MoverController));
+ SubscribeLocalEvent(OnPullStop);
+ SubscribeLocalEvent(OnPullerMove);
+
+ base.Initialize();
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ CommandBinds.Unregister();
+ }
+
+ private void OnPullStop(Entity ent, ref PullStoppedMessage args)
+ {
+ RemCompDeferred(ent);
+ }
+
+ private bool OnRequestMovePulledObject(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+ {
+ if (session?.AttachedEntity is not { } player ||
+ !player.IsValid())
+ {
+ return false;
+ }
+
+ if (!_pullerQuery.TryComp(player, out var pullerComp))
+ return false;
+
+ var pulled = pullerComp.Pulling;
+
+ if (!_pullableQuery.TryComp(pulled, out var pullable))
+ return false;
+
+ if (_container.IsEntityInContainer(player))
+ return false;
+
+ // Cooldown buddy
+ if (_timing.CurTime < pullerComp.NextThrow)
+ return false;
+
+ pullerComp.NextThrow = _timing.CurTime + pullerComp.ThrowCooldown;
+
+ // Cap the distance
+ var range = 2f;
+ var fromUserCoords = coords.WithEntityId(player, EntityManager);
+ var userCoords = new EntityCoordinates(player, Vector2.Zero);
+
+ if (!coords.InRange(EntityManager, TransformSystem, userCoords, range))
+ {
+ var direction = fromUserCoords.Position - userCoords.Position;
+
+ // TODO: Joint API not ass
+ // with that being said I think throwing is the way to go but.
+ if (pullable.PullJointId != null &&
+ TryComp(player, out JointComponent? joint) &&
+ joint.GetJoints.TryGetValue(pullable.PullJointId, out var pullJoint) &&
+ pullJoint is DistanceJoint distance)
+ {
+ range = MathF.Max(0.01f, distance.MaxLength - 0.01f);
+ }
+
+ fromUserCoords = new EntityCoordinates(player, direction.Normalized() * (range - 0.01f));
+ coords = fromUserCoords.WithEntityId(coords.EntityId);
+ }
+
+ EnsureComp(player);
+ var moving = EnsureComp(pulled!.Value);
+ moving.MovingTo = coords;
+ return false;
+ }
+
+ private void OnPullerMove(EntityUid uid, PullMoverComponent component, ref MoveEvent args)
+ {
+ if (!_pullerQuery.TryComp(uid, out var puller))
+ return;
+
+ if (puller.Pulling is not { } pullable)
+ return;
+
+ UpdatePulledRotation(uid, pullable);
+
+ // WHY
+ if (args.NewPosition.EntityId == args.OldPosition.EntityId &&
+ (args.NewPosition.Position - args.OldPosition.Position).LengthSquared() <
+ MinimumMovementDistance * MinimumMovementDistance)
+ {
+ return;
+ }
+
+ if (_physicsQuery.TryComp(uid, out var physics))
+ PhysicsSystem.WakeBody(uid, body: physics);
+
+ StopMove(uid, pullable);
+ }
+
+ private void StopMove(Entity mover, Entity moving)
+ {
+ RemCompDeferred(mover.Owner);
+ RemCompDeferred(moving.Owner);
+ }
+
+ private void UpdatePulledRotation(EntityUid puller, EntityUid pulled)
+ {
+ // TODO: update once ComponentReference works with directed event bus.
+ if (!TryComp(pulled, out RotatableComponent? rotatable))
+ return;
+
+ if (!rotatable.RotateWhilePulling)
+ return;
+
+ var pulledXform = _xformQuery.GetComponent(pulled);
+ var pullerXform = _xformQuery.GetComponent(puller);
+
+ var pullerData = TransformSystem.GetWorldPositionRotation(pullerXform);
+ var pulledData = TransformSystem.GetWorldPositionRotation(pulledXform);
+
+ var dir = pullerData.WorldPosition - pulledData.WorldPosition;
+ if (dir.LengthSquared() > ThresholdRotDistance * ThresholdRotDistance)
+ {
+ var oldAngle = pulledData.WorldRotation;
+ var newAngle = Angle.FromWorldVec(dir);
+
+ var diff = newAngle - oldAngle;
+ if (Math.Abs(diff.Degrees) > ThresholdRotAngle / 2f)
+ {
+ // Ok, so this bit is difficult because ideally it would look like it's snapping to sane angles.
+ // Otherwise PIANO DOOR STUCK! happens.
+ // But it also needs to work with station rotation / align to the local parent.
+ // So...
+ var baseRotation = pulledData.WorldRotation - pulledXform.LocalRotation;
+ var localRotation = newAngle - baseRotation;
+ var localRotationSnapped = Angle.FromDegrees(Math.Floor((localRotation.Degrees / ThresholdRotAngle) + 0.5f) * ThresholdRotAngle);
+ TransformSystem.SetLocalRotation(pulled, localRotationSnapped, pulledXform);
+ }
+ }
+ }
+
+ public override void UpdateBeforeSolve(bool prediction, float frameTime)
+ {
+ base.UpdateBeforeSolve(prediction, frameTime);
+ var movingQuery = EntityQueryEnumerator();
+
+ while (movingQuery.MoveNext(out var pullableEnt, out var mover, out var pullable, out var pullableXform))
+ {
+ if (!mover.MovingTo.IsValid(EntityManager))
+ {
+ RemCompDeferred(pullableEnt);
+ continue;
+ }
+
+ if (pullable.Puller is not {Valid: true} puller)
+ continue;
+
+ var pullerXform = _xformQuery.Get(puller);
+ var pullerPosition = TransformSystem.GetMapCoordinates(pullerXform);
+
+ var movingTo = mover.MovingTo.ToMap(EntityManager, TransformSystem);
+
+ if (movingTo.MapId != pullerPosition.MapId)
+ {
+ RemCompDeferred(pullableEnt);
+ continue;
+ }
+
+ if (!TryComp(pullableEnt, out var physics) ||
+ physics.BodyType == BodyType.Static ||
+ movingTo.MapId != pullableXform.MapID)
+ {
+ RemCompDeferred(pullableEnt);
+ continue;
+ }
+
+ var movingPosition = movingTo.Position;
+ var ownerPosition = TransformSystem.GetWorldPosition(pullableXform);
+
+ var diff = movingPosition - ownerPosition;
+ var diffLength = diff.Length();
+
+ if (diffLength < MaximumSettleDistance && physics.LinearVelocity.Length() < MaximumSettleVelocity)
+ {
+ PhysicsSystem.SetLinearVelocity(pullableEnt, Vector2.Zero, body: physics);
+ RemCompDeferred(pullableEnt);
+ continue;
+ }
+
+ var impulseModifierLerp = Math.Min(1.0f, Math.Max(0.0f, (physics.Mass - AccelModifierLowMass) / (AccelModifierHighMass - AccelModifierLowMass)));
+ var impulseModifier = MathHelper.Lerp(AccelModifierLow, AccelModifierHigh, impulseModifierLerp);
+ var multiplier = diffLength < 1 ? impulseModifier * diffLength : impulseModifier;
+ // Note the implication that the real rules of physics don't apply to pulling control.
+ var accel = diff.Normalized() * multiplier;
+ // Now for the part where velocity gets shutdown...
+ if (diffLength < SettleShutdownDistance && physics.LinearVelocity.Length() >= SettleMinimumShutdownVelocity)
+ {
+ // Shutdown velocity increases as we get closer to centre
+ var scaling = (SettleShutdownDistance - diffLength) / SettleShutdownDistance;
+ accel -= physics.LinearVelocity * SettleShutdownMultiplier * scaling;
+ }
+
+ PhysicsSystem.WakeBody(pullableEnt, body: physics);
+
+ var impulse = accel * physics.Mass * frameTime;
+ PhysicsSystem.ApplyLinearImpulse(pullableEnt, impulse, body: physics);
+
+ // if the puller is weightless or can't move, then we apply the inverse impulse (Newton's third law).
+ // doing it under gravity produces an unsatisfying wiggling when pulling.
+ // If player can't move, assume they are on a chair and we need to prevent pull-moving.
+ if (_gravity.IsWeightless(puller) && pullerXform.Comp.GridUid == null || !_actionBlockerSystem.CanMove(puller))
+ {
+ PhysicsSystem.WakeBody(puller);
+ PhysicsSystem.ApplyLinearImpulse(puller, -impulse);
+ }
+ }
+
+ // Cleanup PullMover
+ var moverQuery = EntityQueryEnumerator();
+
+ while (moverQuery.MoveNext(out var uid, out _, out var puller))
+ {
+ if (!HasComp(puller.Pulling))
+ {
+ RemCompDeferred(uid);
+ continue;
+ }
+ }
+ }
+}
diff --git a/Content.Shared/Movement/Pulling/Components/PullerComponent.cs b/Content.Shared/Movement/Pulling/Components/PullerComponent.cs
index 061ec13ed2..f47ae32f90 100644
--- a/Content.Shared/Movement/Pulling/Components/PullerComponent.cs
+++ b/Content.Shared/Movement/Pulling/Components/PullerComponent.cs
@@ -18,7 +18,7 @@ public sealed partial class PullerComponent : Component
/// Next time the puller can throw what is being pulled.
/// Used to avoid spamming it for infinite spin + velocity.
///
- [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField]
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, Access(Other = AccessPermissions.ReadWriteExecute)]
public TimeSpan NextThrow;
[DataField]
diff --git a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs
index 3de71172c7..225810daed 100644
--- a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs
+++ b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs
@@ -43,8 +43,6 @@ public sealed class PullingSystem : EntitySystem
[Dependency] private readonly SharedHandsSystem _handsSystem = default!;
[Dependency] private readonly SharedInteractionSystem _interaction = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
- [Dependency] private readonly SharedTransformSystem _xformSys = default!;
- [Dependency] private readonly ThrowingSystem _throwing = default!;
public override void Initialize()
{
@@ -66,7 +64,6 @@ public sealed class PullingSystem : EntitySystem
SubscribeLocalEvent(OnDropHandItems);
CommandBinds.Builder
- .Bind(ContentKeyFunctions.MovePulledObject, new PointerInputCmdHandler(OnRequestMovePulledObject))
.Bind(ContentKeyFunctions.ReleasePulledObject, InputCmdHandler.FromDelegate(OnReleasePulledObject, handle: false))
.Register();
}
@@ -245,47 +242,6 @@ public sealed class PullingSystem : EntitySystem
return Resolve(uid, ref component, false) && component.BeingPulled;
}
- private bool OnRequestMovePulledObject(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
- {
- if (session?.AttachedEntity is not { } player ||
- !player.IsValid())
- {
- return false;
- }
-
- if (!TryComp(player, out var pullerComp))
- return false;
-
- var pulled = pullerComp.Pulling;
-
- if (!HasComp(pulled))
- return false;
-
- if (_containerSystem.IsEntityInContainer(player))
- return false;
-
- // Cooldown buddy
- if (_timing.CurTime < pullerComp.NextThrow)
- return false;
-
- pullerComp.NextThrow = _timing.CurTime + pullerComp.ThrowCooldown;
-
- // Cap the distance
- const float range = 2f;
- var fromUserCoords = coords.WithEntityId(player, EntityManager);
- var userCoords = new EntityCoordinates(player, Vector2.Zero);
-
- if (!userCoords.InRange(EntityManager, _xformSys, fromUserCoords, range))
- {
- var userDirection = fromUserCoords.Position - userCoords.Position;
- fromUserCoords = userCoords.Offset(userDirection.Normalized() * range);
- }
-
- Dirty(player, pullerComp);
- _throwing.TryThrow(pulled.Value, fromUserCoords, user: player, strength: 4f, animated: false, recoil: false, playSound: false, doSpin: false);
- return false;
- }
-
public bool IsPulling(EntityUid puller, PullerComponent? component = null)
{
return Resolve(puller, ref component, false) && component.Pulling != null;