diff --git a/Content.Server/_EE/Carrying/BeingCarriedComponent.cs b/Content.Server/_EE/Carrying/BeingCarriedComponent.cs new file mode 100644 index 000000000000..afc78978c867 --- /dev/null +++ b/Content.Server/_EE/Carrying/BeingCarriedComponent.cs @@ -0,0 +1,11 @@ +namespace Content.Server.Carrying +{ + /// + /// Stores the carrier of an entity being carried. + /// + [RegisterComponent] + public sealed partial class BeingCarriedComponent : Component + { + public EntityUid Carrier = default!; + } +} diff --git a/Content.Server/_EE/Carrying/CarriableComponent.cs b/Content.Server/_EE/Carrying/CarriableComponent.cs new file mode 100644 index 000000000000..f1ed950797b8 --- /dev/null +++ b/Content.Server/_EE/Carrying/CarriableComponent.cs @@ -0,0 +1,40 @@ +using System.Threading; + +namespace Content.Server.Carrying +{ + [RegisterComponent] + public sealed partial class CarriableComponent : Component + { + /// + /// Number of free hands required + /// to carry the entity + /// + [DataField] + public int FreeHandsRequired = 2; + + public CancellationTokenSource? CancelToken; + + /// + /// The base duration (In Seconds) of how long it should take to pick up this entity + /// before Contests are considered. + /// + [DataField] + public float PickupDuration = 3; + + // Frontier: min/max sanitization + /// + /// The minimum duration (in seconds) of how long it should take to pick up this entity. + /// When the strongest, heaviest entity picks this up, it should roughly take this long. + /// + [DataField] + public float MinPickupDuration = 1.5f; + + /// + /// The maximum duration (in seconds) of how long it should take to pick up this entity. + /// When an object picks up the heaviest object it can lift, it should be at most this. + /// + [DataField] + public float MaxPickupDuration = 6.0f; + // End Frontier + } +} diff --git a/Content.Server/_EE/Carrying/CarryingComponent.cs b/Content.Server/_EE/Carrying/CarryingComponent.cs new file mode 100644 index 000000000000..e79460595b99 --- /dev/null +++ b/Content.Server/_EE/Carrying/CarryingComponent.cs @@ -0,0 +1,11 @@ +namespace Content.Server.Carrying +{ + /// + /// Added to an entity when they are carrying somebody. + /// + [RegisterComponent] + public sealed partial class CarryingComponent : Component + { + public EntityUid Carried = default!; + } +} diff --git a/Content.Server/_EE/Carrying/CarryingSystem.cs b/Content.Server/_EE/Carrying/CarryingSystem.cs new file mode 100644 index 000000000000..c2ac4ffa294c --- /dev/null +++ b/Content.Server/_EE/Carrying/CarryingSystem.cs @@ -0,0 +1,359 @@ +using System.Numerics; +using System.Threading; +using Content.Server.DoAfter; +using Content.Server.Resist; +using Content.Server.Popups; +using Content.Server.Inventory; +using Content.Shared.Mobs; +using Content.Shared.DoAfter; +using Content.Shared.Buckle.Components; +using Content.Shared.Hands.Components; +using Content.Shared.Hands; +using Content.Shared.Stunnable; +using Content.Shared.Interaction.Events; +using Content.Shared.Verbs; +using Content.Shared.Climbing.Events; +using Content.Shared.Carrying; +using Content.Shared.Contests; +using Content.Shared.Movement.Events; +using Content.Shared.Movement.Systems; +using Content.Shared.Standing; +using Content.Shared.ActionBlocker; +using Content.Shared.Inventory.VirtualItem; +using Content.Shared.Item; +using Content.Shared.Throwing; +using Content.Shared.Movement.Pulling.Components; +using Content.Shared.Movement.Pulling.Events; +using Content.Shared.Movement.Pulling.Systems; +using Content.Shared.Mobs.Systems; +using Content.Shared.Storage; +using Robust.Shared.Map.Components; +using Robust.Shared.Physics.Components; +using Robust.Server.GameObjects; + +namespace Content.Server.Carrying +{ + public sealed class CarryingSystem : EntitySystem + { + [Dependency] private readonly VirtualItemSystem _virtualItemSystem = default!; + [Dependency] private readonly CarryingSlowdownSystem _slowdown = default!; + [Dependency] private readonly DoAfterSystem _doAfterSystem = default!; + [Dependency] private readonly StandingStateSystem _standingState = default!; + [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!; + [Dependency] private readonly PullingSystem _pullingSystem = default!; + [Dependency] private readonly MobStateSystem _mobStateSystem = default!; + [Dependency] private readonly EscapeInventorySystem _escapeInventorySystem = default!; + [Dependency] private readonly PopupSystem _popupSystem = default!; + [Dependency] private readonly MovementSpeedModifierSystem _movementSpeed = default!; + [Dependency] private readonly ContestsSystem _contests = default!; + [Dependency] private readonly TransformSystem _transform = default!; + + public const float BaseDistanceCoeff = 0.5f; // Frontier: default throwing speed reduction + public const float MaxDistanceCoeff = 1.0f; // Frontier: default throwing speed reduction + public const float DefaultMaxThrowDistance = 4.0f; // Frontier: maximum throwing distance + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent>(AddCarryVerb); + SubscribeLocalEvent(OnVirtualItemDeleted); + SubscribeLocalEvent(OnThrow); + SubscribeLocalEvent(OnParentChanged); + SubscribeLocalEvent(OnMobStateChanged); + SubscribeLocalEvent(OnInteractionAttempt); + SubscribeLocalEvent(OnMoveInput); + SubscribeLocalEvent(OnMoveAttempt); + SubscribeLocalEvent(OnStandAttempt); + SubscribeLocalEvent(OnInteractedWith); + SubscribeLocalEvent(OnPullAttempt); + SubscribeLocalEvent(OnStartClimb); + SubscribeLocalEvent(OnBuckleChange); + SubscribeLocalEvent(OnBuckleChange); + SubscribeLocalEvent(OnBuckleChange); + SubscribeLocalEvent(OnBuckleChange); + SubscribeLocalEvent(OnDoAfter); + } + + private void AddCarryVerb(EntityUid uid, CarriableComponent component, GetVerbsEvent args) + { + if (!args.CanInteract || !args.CanAccess || !_mobStateSystem.IsAlive(args.User) + || !CanCarry(args.User, uid, component) + || HasComp(args.User) + || HasComp(args.User) || HasComp(args.Target) + || args.User == args.Target) + return; + + AlternativeVerb verb = new() + { + Act = () => + { + StartCarryDoAfter(args.User, uid, component); + }, + Text = Loc.GetString("carry-verb"), + Priority = 2 + }; + args.Verbs.Add(verb); + } + + /// + /// Since the carried entity is stored as 2 virtual items, when deleted we want to drop them. + /// + private void OnVirtualItemDeleted(EntityUid uid, CarryingComponent component, VirtualItemDeletedEvent args) + { + if (!HasComp(args.BlockingEntity)) + return; + + DropCarried(uid, args.BlockingEntity); + } + + /// + /// Basically using virtual item passthrough to throw the carried person. A new age! + /// Maybe other things besides throwing should use virt items like this... + /// + private void OnThrow(EntityUid uid, CarryingComponent component, ref BeforeThrowEvent args) + { + if (!TryComp(args.ItemUid, out var virtItem) + || !HasComp(virtItem.BlockingEntity)) + return; + + args.ItemUid = virtItem.BlockingEntity; + + var contestCoeff = _contests.MassContest(uid, virtItem.BlockingEntity, false, 2f) // Frontier: "args.throwSpeed *="<"var contestCoeff =" + * _contests.StaminaContest(uid, virtItem.BlockingEntity); + + // Frontier: sanitize our range regardless of CVar values - TODO: variable throw distance ranges (via traits, etc.) + contestCoeff = float.Min(BaseDistanceCoeff * contestCoeff, MaxDistanceCoeff); + if (args.Direction.Length() > DefaultMaxThrowDistance * contestCoeff) + args.Direction = args.Direction.Normalized() * DefaultMaxThrowDistance * contestCoeff; + // End Frontier + } + + private void OnParentChanged(EntityUid uid, CarryingComponent component, ref EntParentChangedMessage args) + { + var xform = Transform(uid); + if (xform.MapUid != args.OldMapId || xform.ParentUid == xform.GridUid) + return; + + DropCarried(uid, component.Carried); + } + + private void OnMobStateChanged(EntityUid uid, CarryingComponent component, MobStateChangedEvent args) + { + DropCarried(uid, component.Carried); + } + + /// + /// Only let the person being carried interact with their carrier and things on their person. + /// + private void OnInteractionAttempt(EntityUid uid, BeingCarriedComponent component, InteractionAttemptEvent args) + { + if (args.Target == null) + return; + + var targetParent = Transform(args.Target.Value).ParentUid; + + if (args.Target.Value != component.Carrier && targetParent != component.Carrier && targetParent != uid) + args.Cancelled = true; + } + + /// + /// Try to escape via the escape inventory system. + /// + private void OnMoveInput(EntityUid uid, BeingCarriedComponent component, ref MoveInputEvent args) + { + if (!TryComp(uid, out var escape) + || !args.HasDirectionalMovement) + return; + + // Check if the victim is in any way incapacitated, and if not make an escape attempt. + // Escape time scales with the inverse of a mass contest. Being lighter makes escape harder. + if (_actionBlockerSystem.CanInteract(uid, component.Carrier)) + { + var disadvantage = _contests.MassContest(component.Carrier, uid, false, 2f); + _escapeInventorySystem.AttemptEscape(uid, component.Carrier, escape, disadvantage); + } + } + + private void OnMoveAttempt(EntityUid uid, BeingCarriedComponent component, UpdateCanMoveEvent args) + { + args.Cancel(); + } + + private void OnStandAttempt(EntityUid uid, BeingCarriedComponent component, StandAttemptEvent args) + { + args.Cancel(); + } + + private void OnInteractedWith(EntityUid uid, BeingCarriedComponent component, GettingInteractedWithAttemptEvent args) + { + if (args.Uid != component.Carrier) + args.Cancelled = true; + } + + private void OnPullAttempt(EntityUid uid, BeingCarriedComponent component, PullAttemptEvent args) + { + args.Cancelled = true; + } + + private void OnStartClimb(EntityUid uid, BeingCarriedComponent component, ref StartClimbEvent args) + { + DropCarried(component.Carrier, uid); + } + + private void OnBuckleChange(EntityUid uid, BeingCarriedComponent component, TEvent args) + { + DropCarried(component.Carrier, uid); + } + + private void OnDoAfter(EntityUid uid, CarriableComponent component, CarryDoAfterEvent args) + { + component.CancelToken = null; + if (args.Handled || args.Cancelled + || !CanCarry(args.Args.User, uid, component)) + return; + + Carry(args.Args.User, uid); + args.Handled = true; + } + private void StartCarryDoAfter(EntityUid carrier, EntityUid carried, CarriableComponent component) + { + if (!TryComp(carrier, out var carrierPhysics) + || !TryComp(carried, out var carriedPhysics) + || carriedPhysics.Mass > carrierPhysics.Mass * 2f) + { + _popupSystem.PopupEntity(Loc.GetString("carry-too-heavy"), carried, carrier, Shared.Popups.PopupType.SmallCaution); + return; + } + + var length = component.PickupDuration // Frontier: removed outer TimeSpan.FromSeconds() + * _contests.MassContest(carriedPhysics, carrierPhysics, false, 4f) + * _contests.StaminaContest(carrier, carried) + * (_standingState.IsDown(carried) ? 0.5f : 1); + + // Frontier: sanitize pickup time duration regardless of CVars - no near-instant pickups. + var duration = TimeSpan.FromSeconds( + float.Clamp(length, + component.MinPickupDuration, + component.MaxPickupDuration)); + // End Frontier + + component.CancelToken = new CancellationTokenSource(); + + var ev = new CarryDoAfterEvent(); + var args = new DoAfterArgs(EntityManager, carrier, duration, ev, carried, target: carried) // Frontier: length(carried, out var pullable)) + _pullingSystem.TryStopPull(carried, pullable); + + _transform.AttachToGridOrMap(carrier); + _transform.AttachToGridOrMap(carried); + _transform.SetCoordinates(carried, Transform(carrier).Coordinates); + _transform.SetParent(carried, carrier); + + _virtualItemSystem.TrySpawnVirtualItemInHand(carried, carrier); + _virtualItemSystem.TrySpawnVirtualItemInHand(carried, carrier); + var carryingComp = EnsureComp(carrier); + ApplyCarrySlowdown(carrier, carried); + var carriedComp = EnsureComp(carried); + EnsureComp(carried); + + carryingComp.Carried = carried; + carriedComp.Carrier = carrier; + + _actionBlockerSystem.UpdateCanMove(carried); + } + + public bool TryCarry(EntityUid carrier, EntityUid toCarry, CarriableComponent? carriedComp = null) + { + if (!Resolve(toCarry, ref carriedComp, false) + || !CanCarry(carrier, toCarry, carriedComp) + || HasComp(carrier) + || HasComp(carrier) + || TryComp(carrier, out var carrierPhysics) + && TryComp(toCarry, out var toCarryPhysics) + && carrierPhysics.Mass < toCarryPhysics.Mass * 2f) + return false; + + Carry(carrier, toCarry); + + return true; + } + + public void DropCarried(EntityUid carrier, EntityUid carried) + { + RemComp(carrier); // get rid of this first so we don't recursively fire that event + RemComp(carrier); + RemComp(carried); + RemComp(carried); + _actionBlockerSystem.UpdateCanMove(carried); + _virtualItemSystem.DeleteInHandsMatching(carrier, carried); + _transform.AttachToGridOrMap(carried); + _standingState.Stand(carried); + _movementSpeed.RefreshMovementSpeedModifiers(carrier); + } + + private void ApplyCarrySlowdown(EntityUid carrier, EntityUid carried) + { + var massRatio = _contests.MassContest(carrier, carried, true); + var massRatioSq = MathF.Pow(massRatio, 2); + var modifier = 1 - 0.15f / massRatioSq; + modifier = Math.Max(0.1f, modifier); + + var slowdownComp = EnsureComp(carrier); + _slowdown.SetModifier(carrier, modifier, modifier, slowdownComp); + } + + public bool CanCarry(EntityUid carrier, EntityUid carried, CarriableComponent? carriedComp = null) + { + if (!Resolve(carried, ref carriedComp, false) + || carriedComp.CancelToken != null + || !HasComp(Transform(carrier).ParentUid) + || HasComp(carrier) + || HasComp(carried) + || !TryComp(carrier, out var hands) + || hands.CountFreeHands() < carriedComp.FreeHandsRequired) + return false; + + return true; + } + + public override void Update(float frameTime) + { + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var carried, out var comp)) + { + var carrier = comp.Carrier; + if (carrier is not { Valid: true } || carried is not { Valid: true }) + continue; + + // SOMETIMES - when an entity is inserted into disposals, or a cryosleep chamber - it can get re-parented without a proper reparent event + // when this happens, it needs to be dropped because it leads to weird behavior + if (Transform(carried).ParentUid != carrier) + { + DropCarried(carrier, carried); + continue; + } + + // Make sure the carried entity is always centered relative to the carrier, as gravity pulls can offset it otherwise + var xform = Transform(carried); + if (!xform.LocalPosition.Equals(Vector2.Zero)) + { + xform.LocalPosition = Vector2.Zero; + } + } + query.Dispose(); + } + } +} diff --git a/Content.Shared/_EE/Carrying/CarryingDoAfterEvent.cs b/Content.Shared/_EE/Carrying/CarryingDoAfterEvent.cs new file mode 100644 index 000000000000..fb7225461cb8 --- /dev/null +++ b/Content.Shared/_EE/Carrying/CarryingDoAfterEvent.cs @@ -0,0 +1,8 @@ +using Robust.Shared.Serialization; +using Content.Shared.DoAfter; + +namespace Content.Shared.Carrying +{ + [Serializable, NetSerializable] + public sealed partial class CarryDoAfterEvent : SimpleDoAfterEvent { } +} diff --git a/Content.Shared/_EE/Carrying/CarryingSlowdownComponent.cs b/Content.Shared/_EE/Carrying/CarryingSlowdownComponent.cs new file mode 100644 index 000000000000..597edc2a7956 --- /dev/null +++ b/Content.Shared/_EE/Carrying/CarryingSlowdownComponent.cs @@ -0,0 +1,28 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; + +namespace Content.Shared.Carrying +{ + [RegisterComponent, NetworkedComponent, Access(typeof(CarryingSlowdownSystem))] + + public sealed partial class CarryingSlowdownComponent : Component + { + [DataField(required: true)] + public float WalkModifier = 1.0f; + + [DataField(required: true)] + public float SprintModifier = 1.0f; + } + + [Serializable, NetSerializable] + public sealed class CarryingSlowdownComponentState : ComponentState + { + public float WalkModifier; + public float SprintModifier; + public CarryingSlowdownComponentState(float walkModifier, float sprintModifier) + { + WalkModifier = walkModifier; + SprintModifier = sprintModifier; + } + } +} diff --git a/Content.Shared/_EE/Carrying/CarryingSlowdownSystem.cs b/Content.Shared/_EE/Carrying/CarryingSlowdownSystem.cs new file mode 100644 index 000000000000..04b714fdd786 --- /dev/null +++ b/Content.Shared/_EE/Carrying/CarryingSlowdownSystem.cs @@ -0,0 +1,46 @@ +using Content.Shared.Movement.Systems; +using Robust.Shared.GameStates; + +namespace Content.Shared.Carrying +{ + public sealed class CarryingSlowdownSystem : EntitySystem + { + [Dependency] private readonly MovementSpeedModifierSystem _movementSpeed = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnGetState); + SubscribeLocalEvent(OnHandleState); + SubscribeLocalEvent(OnRefreshMoveSpeed); + } + + public void SetModifier(EntityUid uid, float walkSpeedModifier, float sprintSpeedModifier, CarryingSlowdownComponent? component = null) + { + if (!Resolve(uid, ref component)) + return; + + component.WalkModifier = walkSpeedModifier; + component.SprintModifier = sprintSpeedModifier; + _movementSpeed.RefreshMovementSpeedModifiers(uid); + } + private void OnGetState(EntityUid uid, CarryingSlowdownComponent component, ref ComponentGetState args) + { + args.State = new CarryingSlowdownComponentState(component.WalkModifier, component.SprintModifier); + } + + private void OnHandleState(EntityUid uid, CarryingSlowdownComponent component, ref ComponentHandleState args) + { + if (args.Current is not CarryingSlowdownComponentState state) + return; + + component.WalkModifier = state.WalkModifier; + component.SprintModifier = state.SprintModifier; + _movementSpeed.RefreshMovementSpeedModifiers(uid); + } + private void OnRefreshMoveSpeed(EntityUid uid, CarryingSlowdownComponent component, RefreshMovementSpeedModifiersEvent args) + { + args.ModifySpeed(component.WalkModifier, component.SprintModifier); + } + } +}