diff --git a/Content.Client/_Scp/Holding/ScpHoldingSystem.cs b/Content.Client/_Scp/Holding/ScpHoldingSystem.cs new file mode 100644 index 00000000000..e3f2cbeb3b3 --- /dev/null +++ b/Content.Client/_Scp/Holding/ScpHoldingSystem.cs @@ -0,0 +1,301 @@ +using Content.Client.Hands.Systems; +using Content.Client.Inventory; +using Content.Shared._Scp.Holding.Components; +using Content.Shared._Scp.Holding.Systems; +using Content.Shared.Hands; +using Content.Shared.Hands.Components; +using Content.Shared.Input; +using Content.Shared.Inventory.VirtualItem; +using Robust.Client.Physics; +using Robust.Client.Player; +using Robust.Shared.Input.Binding; +using Robust.Shared.Map; +using Robust.Shared.Physics.Components; +using Robust.Shared.Player; +using Robust.Shared.Timing; + +namespace Content.Client._Scp.Holding; + +public sealed partial class ScpHoldingSystem : SharedScpHoldingSystem +{ + [Dependency] private readonly HandsSystem _hands = default!; + [Dependency] private readonly Robust.Client.Physics.PhysicsSystem _physics = default!; + [Dependency] private readonly VirtualItemSystem _virtualItem = default!; + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + private EntityUid? _trackedHolderTarget; + private readonly List _authoritativeBlockers = []; + private readonly List _predictedBlockers = []; + + private EntityQuery _handsQuery; + private EntityQuery _holdableQuery; + private EntityQuery _blockerQuery; + private EntityQuery _activeHolderQuery; + private EntityQuery _virtualItemQuery; + + public override void Initialize() + { + base.Initialize(); + + CommandBinds.Builder + .Bind(ContentKeyFunctions.MovePulledObject, new PointerInputCmdHandler(OnMoveHeldToCursor)) + .Register(); + + _handsQuery = GetEntityQuery(); + _holdableQuery = GetEntityQuery(); + _blockerQuery = GetEntityQuery(); + _activeHolderQuery = GetEntityQuery(); + _virtualItemQuery = GetEntityQuery(); + + SubscribeLocalEvent(OnHeldAfterState); + SubscribeLocalEvent(OnHolderAfterState); + SubscribeLocalEvent(OnBlockerStartup); + SubscribeLocalEvent(OnBlockerEquipped); + SubscribeLocalEvent(OnUpdateHeldPredicted); + } + + public override void Shutdown() + { + base.Shutdown(); + CommandBinds.Unregister(); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + if (_player.LocalEntity is not { Valid: true } local) + { + UpdateTrackedLocalHeldTarget(null); + return; + } + + if (!_activeHolderQuery.TryComp(local, out var localHolder)) + { + UpdateTrackedLocalHeldTarget(null); + return; + } + + ReconcileLocalHolderState((local, localHolder)); + } + + private void OnHeldAfterState(Entity ent, ref AfterAutoHandleStateEvent args) + { + ReconcileHeldAfterState(ent); + } + + private void OnHolderAfterState(Entity ent, ref AfterAutoHandleStateEvent args) + { + if (_player.LocalEntity != ent) + return; + + ReconcileLocalHolderState(ent); + } + + private void OnBlockerStartup(Entity ent, ref ComponentStartup args) + { + if (!_timing.ApplyingState) + return; + + ReconcileLocalHolderBlocker(ent); + } + + private void OnBlockerEquipped(Entity ent, ref GotEquippedHandEvent args) + { + if (!_timing.ApplyingState) + return; + + ReconcileLocalHolderBlocker(ent, args.User); + } + + private void OnUpdateHeldPredicted(Entity ent, ref UpdateIsPredictedEvent args) + { + if (_player.LocalEntity is not { Valid: true } local) + return; + + if (ent.Owner == local) + { + args.IsPredicted = true; + return; + } + + if (_activeHolderQuery.TryComp(local, out var localHolder)) + { + if (localHolder.Target == ent) + { + args.IsPredicted = true; + return; + } + } + + foreach (var holder in ent.Comp.Holders) + { + if (holder != local) + continue; + + args.IsPredicted = true; + return; + } + + if (ent.Comp.Holders.Count > 0) + args.BlockPrediction = true; + } + + private bool OnMoveHeldToCursor(ICommonSession? session, EntityCoordinates coords, EntityUid uid) + { + if (_player.LocalEntity is not { Valid: true } local) + return false; + + TryMoveHeldToCursor(local, coords); + return false; + } + + private void ReconcileHeldAfterState(Entity held) + { + _physics.UpdateIsPredicted(held); + + if (HasComp(held)) + SyncPlaceholderHands(held); + } + + protected override void UpdateHeldStates() + { + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var held, out var physics)) + { + if (!physics.Predict) + continue; + + _physics.UpdateIsPredicted(uid); + UpdateHeld((uid, held)); + } + } + + protected override void OnHeldStateShutdown(Entity held) + { + _physics.UpdateIsPredicted(held); + } + + private void ReconcileLocalHolderBlocker(EntityUid blocker, EntityUid? holderUid = null) + { + holderUid ??= _player.LocalEntity; + + if (holderUid is not { Valid: true } holder) + return; + + if (!_activeHolderQuery.TryComp(holder, out var activeHolder)) + return; + + if (activeHolder.Target == null) + return; + + if (!_virtualItemQuery.TryComp(blocker, out var virtualItem)) + return; + + if (virtualItem.BlockingEntity != activeHolder.Target.Value) + return; + + if (!_handsQuery.TryComp(holder, out var hands)) + return; + + if (!_hands.IsHolding((holder, hands), blocker)) + return; + + ReconcileLocalHolderState((holder, activeHolder)); + } + + private void ReconcileLocalHolderState(Entity holder) + { + UpdateTrackedLocalHeldTarget(holder, holder.Comp.Target); + ReconcileLocalHolderBlockerSteadyState(holder); + } + + private void ReconcileLocalHolderBlockerSteadyState(Entity holder) + { + if (holder.Comp.Target == null) + return; + + if (!_handsQuery.TryComp(holder, out var hands)) + return; + + var target = holder.Comp.Target.Value; + if (!_holdableQuery.TryComp(target, out var holdable)) + return; + + var requiredHolderHandCount = GetRequiredHolderHandCount(holdable); + _authoritativeBlockers.Clear(); + _predictedBlockers.Clear(); + + Entity holderHands = (holder, hands); + + foreach (var heldItem in _hands.EnumerateHeld(holderHands)) + { + if (!_virtualItemQuery.TryComp(heldItem, out var virtualItem)) + continue; + + if (virtualItem.BlockingEntity != target) + continue; + + if (!IsClientSide(heldItem)) + { + _authoritativeBlockers.Add(heldItem); + continue; + } + + if (!_blockerQuery.HasComp(heldItem)) + continue; + + _predictedBlockers.Add(heldItem); + } + + var requiredPredictedBlockerCount = Math.Max(requiredHolderHandCount - _authoritativeBlockers.Count, 0); + for (var i = requiredPredictedBlockerCount; i < _predictedBlockers.Count; i++) + { + QueueDel(_predictedBlockers[i]); + } + + if (_timing.ApplyingState) + { + return; + } + + var currentPredictedBlockerCount = Math.Min(_predictedBlockers.Count, requiredPredictedBlockerCount); + while (currentPredictedBlockerCount < requiredPredictedBlockerCount) + { + if (!_hands.TryGetEmptyHand(holderHands, out var emptyHand)) + break; + + if (!_virtualItem.TrySpawnVirtualItem(target, holder, out var spawnedVirtualItem)) + break; + + EnsureComp(spawnedVirtualItem.Value); + _hands.DoPickup(holder, emptyHand, spawnedVirtualItem.Value, hands); + currentPredictedBlockerCount++; + } + } + + private void UpdateTrackedLocalHeldTarget(EntityUid? currentTarget, EntityUid? previousTarget = null) + { + if (_trackedHolderTarget == currentTarget) + return; + + previousTarget ??= _trackedHolderTarget; + + if (previousTarget != null) + _physics.UpdateIsPredicted(previousTarget.Value); + + _trackedHolderTarget = currentTarget; + + if (_trackedHolderTarget != null) + _physics.UpdateIsPredicted(_trackedHolderTarget.Value); + } + + private void UpdateTrackedLocalHeldTarget(EntityUid holderUid, EntityUid? currentTarget, EntityUid? previousTarget = null) + { + if (_player.LocalEntity != holderUid) + return; + + UpdateTrackedLocalHeldTarget(currentTarget, previousTarget); + } +} diff --git a/Content.IntegrationTests/Tests/_Scp/ScpHeadsetEncryptionKeysTest.cs b/Content.IntegrationTests/Tests/_Scp/ScpHeadsetEncryptionKeysTest.cs deleted file mode 100644 index 54b7878f772..00000000000 --- a/Content.IntegrationTests/Tests/_Scp/ScpHeadsetEncryptionKeysTest.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq; -using Content.IntegrationTests.Tests.Interaction; -using Content.Shared.Radio.Components; - -namespace Content.IntegrationTests.Tests._Scp; - -public sealed class ScpHeadsetEncryptionKeysTest : InteractionTest -{ - [Test] - public async Task ScpHeadsetsRejectCommonKeys() - { - await SpawnTarget("ClothingHeadsetScientificService"); - var comp = Comp(); - - Assert.Multiple(() => - { - Assert.That(comp.KeyContainer.ContainedEntities, Has.Count.EqualTo(1)); - Assert.That(comp.DefaultChannel, Is.EqualTo("ScientificService")); - Assert.That(comp.Channels, Has.Count.EqualTo(1)); - Assert.That(comp.Channels.First(), Is.EqualTo("ScientificService")); - }); - - await InteractUsing("EncryptionKeyCommon"); - - Assert.Multiple(() => - { - Assert.That(comp.KeyContainer.ContainedEntities, Has.Count.EqualTo(1)); - Assert.That(comp.DefaultChannel, Is.EqualTo("ScientificService")); - Assert.That(comp.Channels, Has.Count.EqualTo(1)); - Assert.That(comp.Channels.First(), Is.EqualTo("ScientificService")); - }); - } -} diff --git a/Content.Server/Movement/Systems/PullController.cs b/Content.Server/Movement/Systems/PullController.cs index f2bbdc247b1..20b7e7235bb 100644 --- a/Content.Server/Movement/Systems/PullController.cs +++ b/Content.Server/Movement/Systems/PullController.cs @@ -1,4 +1,7 @@ using System.Numerics; +using Content.Server._Scp.Holding; +using Content.Shared._Scp.Holding.Components; +using Content.Shared._Scp.Holding.Systems; using Content.Server.Movement.Components; using Content.Server.Physics.Controllers; using Content.Shared.ActionBlocker; @@ -59,6 +62,7 @@ public sealed class PullController : VirtualController [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!; [Dependency] private readonly SharedContainerSystem _container = default!; [Dependency] private readonly SharedGravitySystem _gravity = default!; + [Dependency] private readonly ScpHoldingSystem _scpHolding = default!; [Dependency] private readonly SharedTransformSystem _transformSystem = default!; /// @@ -118,6 +122,11 @@ private bool OnRequestMovePulledObject(ICommonSession? session, EntityCoordinate return false; } + // Fire edit start - route cursor-move through ScpHolding before vanilla pulling handles the same input. + if (_scpHolding.TryMoveHeldToCursor(player, coords)) + return false; + // Fire edit end + if (!_pullerQuery.TryComp(player, out var pullerComp)) return false; diff --git a/Content.Server/_Scp/Holding/ScpHoldingSystem.cs b/Content.Server/_Scp/Holding/ScpHoldingSystem.cs new file mode 100644 index 00000000000..5e35960338e --- /dev/null +++ b/Content.Server/_Scp/Holding/ScpHoldingSystem.cs @@ -0,0 +1,40 @@ +using Content.Shared.Hands; +using Content.Shared._Scp.Holding.Components; +using Content.Shared._Scp.Holding.Systems; + +namespace Content.Server._Scp.Holding; + +public sealed partial class ScpHoldingSystem : SharedScpHoldingSystem +{ + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnHoldShutdown); + SubscribeLocalEvent(OnHandCountChanged); + } + + protected override void OnHeldStateShutdown(Entity held) + { + foreach (var holderUid in held.Comp.Holders.ToArray()) + { + RemComp(holderUid); + } + } + + private void OnHoldShutdown(Entity ent, ref ComponentShutdown args) + { + if (!TryComp(ent, out var holder)) + return; + + if (holder.Target == null) + return; + + ReleaseHolderContribution(ent, holder.Target.Value, clearIfEmpty: true); + } + + private void OnHandCountChanged(Entity ent, ref HandCountChangedEvent args) + { + SyncHeldState(ent); + } +} diff --git a/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs b/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs index 94ab593f905..2102cb71832 100644 --- a/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs +++ b/Content.Shared/Hands/EntitySystems/SharedHandsSystem.Drop.cs @@ -77,7 +77,7 @@ public bool CanDropHeld(EntityUid uid, string handId, bool checkActionBlocker = if (!ContainerSystem.TryGetContainer(uid, handId, out var container)) return false; - if (container.ContainedEntities.FirstOrNull() is not {} held) + if (container.ContainedEntities.FirstOrNull() is not { } held) return false; if (!ContainerSystem.CanRemove(held, container)) @@ -139,9 +139,13 @@ public bool TryDrop(Entity ent, string handId, EntityCoordinate return false; // Fire added end - // if item is a fake item (like with pulling), just delete it rather than bothering with trying to drop it into the world - if (TryComp(entity, out VirtualItemComponent? @virtual)) - _virtualSystem.DeleteVirtualItem((entity.Value, @virtual), ent); + // Fire edit start - virtual items should leave the hand through the normal removal path, not through world drop handling + if (TryComp(entity, out VirtualItemComponent? _)) + { + DoDrop(ent, handId, doDropInteraction: false); + return true; + } + // Fire edit end if (TerminatingOrDeleted(entity)) return true; diff --git a/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs b/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs index ada0542c4f7..fa432ffd254 100644 --- a/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs +++ b/Content.Shared/Interaction/SharedInteractionSystem.Blocking.cs @@ -41,11 +41,16 @@ private void CancelUseEvent(Entity ent, ref UseAttemptEv private void OnMoveAttempt(EntityUid uid, BlockMovementComponent component, UpdateCanMoveEvent args) { + // Fire edit start - do not let a blocker cancel its own shutdown refresh + if (component.LifeStage > ComponentLifeStage.Running) + return; + // If we're relaying then don't cancel. if (HasComp(uid)) return; args.Cancel(); // no more scurrying around + // Fire edit end } private void CancellableInteractEvent(EntityUid uid, BlockMovementComponent component, CancellableEntityEventArgs args) diff --git a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs index 9a1222cf2b9..0b2713b4c9d 100644 --- a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs +++ b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs @@ -39,7 +39,9 @@ namespace Content.Shared.Movement.Pulling.Systems; /// /// Allows one entity to pull another behind them via a physics distance joint. /// -public sealed class PullingSystem : EntitySystem +// Fire edit start - enable _Scp partial hook +public sealed partial class PullingSystem : EntitySystem +// Fire edit end { [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly MobStateSystem _mobState = default!; // Sunrise-edit @@ -60,6 +62,10 @@ public override void Initialize() { base.Initialize(); + // Fire added start - initialize _Scp hold redirect caches + InitializeScpHolding(); + // Fire added end + UpdatesAfter.Add(typeof(SharedPhysicsSystem)); UpdatesOutsidePrediction = true; @@ -523,6 +529,11 @@ public bool TryStartPull(EntityUid pullerUid, EntityUid pullableUid, if (pullerComp.Pulling == pullableUid) return true; + // Fire added start - redirect scp-hold-capable pull attempts into the hold flow + if (TryRedirectPullToScpHold(pullerUid, pullableUid, pullerComp, pullableComp, out var holdSuccess)) + return holdSuccess; + // Fire added end + if (!CanPull(pullerUid, pullableUid)) return false; diff --git a/Content.Shared/_Scp/Holding/Compatibility/PullingSystem.ScpHolding.cs b/Content.Shared/_Scp/Holding/Compatibility/PullingSystem.ScpHolding.cs new file mode 100644 index 00000000000..e1fc8e8db1a --- /dev/null +++ b/Content.Shared/_Scp/Holding/Compatibility/PullingSystem.ScpHolding.cs @@ -0,0 +1,69 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared._Scp.Holding.Systems; +using Content.Shared.Movement.Pulling.Components; + +#pragma warning disable IDE0130 +namespace Content.Shared.Movement.Pulling.Systems; + +public sealed partial class PullingSystem +{ + [Dependency] private readonly SharedScpHoldingSystem _scpHolding = default!; + + private EntityQuery _pullableQuery; + private EntityQuery _scpActiveHolderQuery; + + private void InitializeScpHolding() + { + _pullableQuery = GetEntityQuery(); + _scpActiveHolderQuery = GetEntityQuery(); + } + + /// + /// Attempts to consume a pull request by redirecting it into SCP holding. + /// Returns when the pull attempt was handled, even if it was rejected. + /// The output indicates whether the redirect actually succeeded. + /// This includes the paths that validate via , + /// stop existing pulls via TryStopPull, and finally toggle the hold via + /// . + /// + private bool TryRedirectPullToScpHold(EntityUid pullerUid, EntityUid pullableUid, + PullerComponent pullerComp, PullableComponent pullableComp, out bool success) + { + success = false; + + if (!_scpHolding.CanRedirectPullToScpHold(pullerUid, pullableUid)) + return false; + + var holdComp = Comp(pullerUid); + var holder = (pullerUid, holdComp); + + if (_scpActiveHolderQuery.TryComp(pullerUid, out var activeHolder) && + activeHolder.Target != null) + { + success = _scpHolding.TryToggleHold(holder, pullableUid); + return true; + } + + if (!_scpHolding.CanToggleHold(holder, + pullableUid, + ignoreHandAvailability: pullerComp.Pulling != null, + checkAttempt: true)) + return true; + + if (pullerComp.Pulling is { } currentPullUid && + _pullableQuery.TryComp(currentPullUid, out var currentPull) && + !TryStopPull(currentPullUid, currentPull, pullerUid)) + { + return true; + } + + if (pullableComp.Puller != null && + !TryStopPull(pullableUid, pullableComp, pullableComp.Puller)) + { + return true; + } + + success = _scpHolding.TryToggleHold(holder, pullableUid, attemptChecked: true); + return true; + } +} diff --git a/Content.Shared/_Scp/Holding/Components/ActiveScpHoldableComponent.cs b/Content.Shared/_Scp/Holding/Components/ActiveScpHoldableComponent.cs new file mode 100644 index 00000000000..53e4c2e544e --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ActiveScpHoldableComponent.cs @@ -0,0 +1,31 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Runtime state stored on a target while at least one holder is contributing. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true, true), AutoGenerateComponentPause] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ActiveScpHoldableComponent : Component +{ + /// + /// Next timestamp when a soft breakout attempt may succeed. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField] + public TimeSpan SoftEscapeAvailableAt; + + /// + /// Ordered holder list used for contribution counting and per-holder runtime coordination. + /// + [AutoNetworkedField, ViewVariables] + public List Holders = []; + + /// + /// Required contributor count for entering full hold. + /// + [AutoNetworkedField, ViewVariables] + public int RequiredHolderCount = 2; +} diff --git a/Content.Shared/_Scp/Holding/Components/ActiveScpHolderComponent.cs b/Content.Shared/_Scp/Holding/Components/ActiveScpHolderComponent.cs new file mode 100644 index 00000000000..8d25b646f7c --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ActiveScpHolderComponent.cs @@ -0,0 +1,33 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; +using Robust.Shared.Map; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Runtime contribution state stored on each active holder. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(raiseAfterAutoHandleState: true, fieldDeltas: true)] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ActiveScpHolderComponent : Component +{ + /// + /// Target currently being contributed to. + /// + [AutoNetworkedField, ViewVariables] + public EntityUid? Target; + + /// + /// Raw per-holder desired cursor target in world space. + /// Each tick it is clamped relative to the holder's current position, + /// so far clicks keep their direction even if the holder moves. + /// + [AutoNetworkedField, ViewVariables] + public EntityCoordinates CursorTargetCoordinates = EntityCoordinates.Invalid; + + /// + /// True while this holder is still actively pulling the held target toward its stored cursor target. + /// + [AutoNetworkedField, ViewVariables] + public bool CursorMoveActive; +} diff --git a/Content.Shared/_Scp/Holding/Components/ActiveStateScpHoldableFullHoldComponent.cs b/Content.Shared/_Scp/Holding/Components/ActiveStateScpHoldableFullHoldComponent.cs new file mode 100644 index 00000000000..775f6ccd933 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ActiveStateScpHoldableFullHoldComponent.cs @@ -0,0 +1,19 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Runtime full-hold state stored on a target while it is immobilized. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ActiveStateScpHoldableFullHoldComponent : Component +{ + /// + /// Timestamp when the current uninterrupted full hold started. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField] + public TimeSpan StartedAt; +} diff --git a/Content.Shared/_Scp/Holding/Components/ActiveStateScpHolderSlowdownComponent.cs b/Content.Shared/_Scp/Holding/Components/ActiveStateScpHolderSlowdownComponent.cs new file mode 100644 index 00000000000..d3518f13e68 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ActiveStateScpHolderSlowdownComponent.cs @@ -0,0 +1,24 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Runtime slowdown state stored on an active holder while their movement is penalized. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true)] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ActiveStateScpHolderSlowdownComponent : Component +{ + /// + /// Walk speed modifier applied while the holder contributes to an active hold. + /// + [AutoNetworkedField, ViewVariables] + public float WalkModifier = 1f; + + /// + /// Sprint speed modifier applied while the holder contributes to an active hold. + /// + [AutoNetworkedField, ViewVariables] + public float SprintModifier = 1f; +} diff --git a/Content.Shared/_Scp/Holding/Components/ScpBreakoutAttemptComponent.cs b/Content.Shared/_Scp/Holding/Components/ScpBreakoutAttemptComponent.cs new file mode 100644 index 00000000000..557360f0f77 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ScpBreakoutAttemptComponent.cs @@ -0,0 +1,11 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Semantic state that marks an active breakout attempt during a full hold. +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpBreakoutAttemptComponent : Component; diff --git a/Content.Shared/_Scp/Holding/Components/ScpHeldHandBlockerComponent.cs b/Content.Shared/_Scp/Holding/Components/ScpHeldHandBlockerComponent.cs new file mode 100644 index 00000000000..e31e2c1addf --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ScpHeldHandBlockerComponent.cs @@ -0,0 +1,13 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Marks a victim hand placeholder virtual item created by SCP holding. +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHeldHandBlockerComponent : Component +{ +} diff --git a/Content.Shared/_Scp/Holding/Components/ScpHoldHandBlockerComponent.cs b/Content.Shared/_Scp/Holding/Components/ScpHoldHandBlockerComponent.cs new file mode 100644 index 00000000000..f1e945eb306 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ScpHoldHandBlockerComponent.cs @@ -0,0 +1,13 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Marks a virtual item that reserves one holder hand for an active SCP hold. +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHoldHandBlockerComponent : Component +{ +} diff --git a/Content.Shared/_Scp/Holding/Components/ScpHoldImmuneComponent.cs b/Content.Shared/_Scp/Holding/Components/ScpHoldImmuneComponent.cs new file mode 100644 index 00000000000..b2b0035a066 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ScpHoldImmuneComponent.cs @@ -0,0 +1,19 @@ +using Content.Shared._Scp.Holding.Systems; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Prevents the target from being held again for a short period after a successful full breakout. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHoldImmuneComponent : Component +{ + /// + /// Timestamp when the immunity expires. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField] + public TimeSpan ExpiresAt; +} diff --git a/Content.Shared/_Scp/Holding/Components/ScpHoldRestrictedComponent.cs b/Content.Shared/_Scp/Holding/Components/ScpHoldRestrictedComponent.cs new file mode 100644 index 00000000000..42a52fd8613 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ScpHoldRestrictedComponent.cs @@ -0,0 +1,10 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding.Components; + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class ScpHoldRestrictedComponent : Component +{ + [DataField, AutoNetworkedField] + public ScpHoldStage Stage = ScpHoldStage.Full; +} diff --git a/Content.Shared/_Scp/Holding/Components/ScpHoldableComponent.cs b/Content.Shared/_Scp/Holding/Components/ScpHoldableComponent.cs new file mode 100644 index 00000000000..ef34dc24761 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ScpHoldableComponent.cs @@ -0,0 +1,148 @@ +using Content.Shared._Scp.Other.WorldAlert; +using Content.Shared.Whitelist; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Marks an entity as a valid target for the SCP holding mechanic and stores per-target hold tuning. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class ScpHoldableComponent : Component +{ + /// + /// Optional whitelist of entities that may hold this target. + /// + [DataField, AutoNetworkedField] + public EntityWhitelist? HolderWhitelist; + + /// + /// Optional blacklist of entities that may not hold this target. + /// + [DataField, AutoNetworkedField] + public EntityWhitelist? HolderBlacklist; + + /// + /// Number of hands each holder must reserve to contribute to holding this target. + /// + [DataField, AutoNetworkedField] + public int HolderHandsRequired = 1; + + /// + /// Minimum uninterrupted full hold duration before a breakout do-after may start. + /// + [DataField, AutoNetworkedField] + public TimeSpan FullHoldDelay = TimeSpan.FromSeconds(10); + + /// + /// Duration of the visible breakout do-after for a full hold. + /// + [DataField, AutoNetworkedField] + public TimeSpan FullBreakoutDuration = TimeSpan.FromSeconds(5); + + /// + /// Duration of immunity after a successful full breakout. + /// + [DataField, AutoNetworkedField] + public TimeSpan PostBreakoutImmunity = TimeSpan.FromSeconds(5); + + /// + /// Optional visual and audio feedback played when a full-hold breakout attempt starts. + /// + [DataField, AutoNetworkedField] + public WorldAlertSettings BreakoutAttemptAlertSettings = new() + { + Prototype = "WorldAlertHandcuffs", + Sound = new SoundCollectionSpecifier("storageRustle", + AudioParams.Default.WithVolume(-2f).WithMaxDistance(4f).WithVariation(0.15f)), + DirectSound = true, + }; + + /// + /// Maximum unobstructed range allowed between holder and target. + /// + [DataField, AutoNetworkedField] + public float HoldRange = 1f; + + /// + /// Scales the preferred soft-drag distance from the configured hold range. + /// + [DataField, AutoNetworkedField] + public float SoftDragDistanceFactor = 0.3f; + + /// + /// Lower clamp for the preferred soft-drag distance. + /// + [DataField, AutoNetworkedField] + public float SoftDragMinimumDistance = 0.4f; + + /// + /// Upper clamp for the preferred soft-drag distance. + /// + [DataField, AutoNetworkedField] + public float SoftDragMaximumDistance = 0.6f; + + /// + /// Distance where the system snaps to the holder-facing direction instead of offset. + /// + [DataField, AutoNetworkedField] + public float SoftDragSnapTolerance = 0.03f; + + /// + /// Distance where the held target is considered settled and only matches holder velocity. + /// + [DataField, AutoNetworkedField] + public float SoftDragSettleTolerance = 0.08f; + + /// + /// Minimum velocity used to derive drag direction from holder movement. + /// + [DataField, AutoNetworkedField] + public float SoftDragVelocityDirectionThreshold = 0.05f; + + /// + /// Minimum time window used to catch the held target back up to its desired position. + /// + [DataField, AutoNetworkedField] + public float SoftDragCatchUpTime = 0.05f; + + /// + /// Maximum correction speed applied while soft-dragging the held target. + /// + [DataField, AutoNetworkedField] + public float SoftDragMaximumCorrectionSpeed = 6f; + + /// + /// Extra correction strength applied when the held target moves away from its desired position. + /// + [DataField, AutoNetworkedField] + public float SoftDragAwayVelocityStrength = 0.6f; + + /// + /// Velocity difference threshold before the held body's velocity is updated. + /// + [DataField, AutoNetworkedField] + public float SoftDragVelocityTolerance = 0.05f; + + /// + /// Walk speed modifier applied to holders while they move this target. + /// Lower values make the target heavier to move. + /// + [DataField, AutoNetworkedField] + public float HolderWalkModifier = 0.7f; + + /// + /// Sprint speed modifier applied to holders while they move this target. + /// Lower values make the target heavier to move. + /// + [DataField, AutoNetworkedField] + public float HolderSprintModifier = 0.7f; + + /// + /// Optional speed modifier applied only while the held target is moved toward the cursor. + /// If not set, is used. + /// + [DataField, AutoNetworkedField] + public float? CursorMoveSpeedModifier; +} diff --git a/Content.Shared/_Scp/Holding/Components/ScpHolderComponent.cs b/Content.Shared/_Scp/Holding/Components/ScpHolderComponent.cs new file mode 100644 index 00000000000..72a2564c9e8 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Components/ScpHolderComponent.cs @@ -0,0 +1,48 @@ +using Content.Shared._Scp.Holding.Systems; +using Content.Shared._Scp.Other.WorldAlert; +using Content.Shared.Whitelist; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; + +namespace Content.Shared._Scp.Holding.Components; + +/// +/// Grants the owner the ability to contribute to SCP holding. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true), AutoGenerateComponentPause] +[Access(typeof(SharedScpHoldingSystem))] +public sealed partial class ScpHolderComponent : Component +{ + /// + /// Next timestamp when this entity may start a new hold contribution. + /// + [AutoNetworkedField, AutoPausedField] + public TimeSpan? HoldAvailableAt; + + /// + /// Optional whitelist of entities this holder may grab. + /// + [DataField, AutoNetworkedField] + public EntityWhitelist? HoldableWhitelist; + + /// + /// Optional blacklist of entities this holder may not grab. + /// + [DataField, AutoNetworkedField] + public EntityWhitelist? HoldableBlacklist; + + /// + /// Cooldown applied after each successful hold contribution start. + /// + [DataField, AutoNetworkedField] + public TimeSpan HoldActionCooldown = TimeSpan.FromSeconds(1); + + [DataField, AutoNetworkedField] + public WorldAlertSettings BreakoutAttemptAlertSettings = new() + { + Prototype = "WhistleExclamation", + Sound = new SoundCollectionSpecifier("storageRustle", + AudioParams.Default.WithVolume(-2f).WithMaxDistance(4f).WithVariation(0.15f)), + DirectSound = true, + }; +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldStage.cs b/Content.Shared/_Scp/Holding/ScpHoldStage.cs new file mode 100644 index 00000000000..24c37d4be3b --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldStage.cs @@ -0,0 +1,7 @@ +namespace Content.Shared._Scp.Holding; + +public enum ScpHoldStage +{ + Soft, + Full, +} diff --git a/Content.Shared/_Scp/Holding/ScpHoldingEvents.cs b/Content.Shared/_Scp/Holding/ScpHoldingEvents.cs new file mode 100644 index 00000000000..ed7492b63fd --- /dev/null +++ b/Content.Shared/_Scp/Holding/ScpHoldingEvents.cs @@ -0,0 +1,27 @@ +using Content.Shared.Alert; +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +namespace Content.Shared._Scp.Holding; + +public sealed partial class ScpHoldBreakoutAlertEvent : BaseAlertEvent; + +[ByRefEvent] +public record struct ScpHoldAttemptEvent(EntityUid Holder, EntityUid Target) +{ + public bool Cancelled; +} + +[ByRefEvent] +public readonly record struct ScpHoldBreakoutEvent(bool ViaMovement, bool WasFullHold, bool AppliedImmunity); + +[Serializable, NetSerializable] +public sealed partial class ScpHoldBreakoutDoAfterEvent : SimpleDoAfterEvent +{ + public bool ViaMovement; + + public ScpHoldBreakoutDoAfterEvent(bool viaMovement = false) + { + ViaMovement = viaMovement; + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Actions.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Actions.cs new file mode 100644 index 00000000000..71234c55163 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Actions.cs @@ -0,0 +1,377 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared.DoAfter; +using Content.Shared.Movement.Components; +using Robust.Shared.Physics; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + /* + * Hold-local query caches, hold toggling API, breakout flow, and cooldown helpers. + */ + + private EntityQuery _moverQuery; + private EntityQuery _holdableQuery; + + private void InitializeHoldQueries() + { + _moverQuery = GetEntityQuery(); + _holdableQuery = GetEntityQuery(); + } + + public bool TryToggleHold(Entity holder, EntityUid target, bool attemptChecked = false) + { + if (_activeHolderQuery.TryComp(holder, out var activeHolder) && activeHolder.Target != null) + { + if (activeHolder.Target.Value == target) + return TryReleaseHold(holder, target); + + _popup.PopupClient(Loc.GetString("scp-hold-already-holding-other"), holder); + return false; + } + + if (!CanStartHold(holder)) + return false; + + if (!CanToggleHold(holder, target, checkAttempt: !attemptChecked)) + return false; + + var held = EnsureHeldState(target); + AddHolderContribution(holder, held); + SyncHeldState(held); + + StartHoldCooldown(holder); + return true; + } + + public bool TryReleaseHold(Entity holder, EntityUid target) + { + if (!CanReleaseHold(holder, target)) + return false; + + ReleaseHolderContribution(holder, target, clearIfEmpty: true); + return true; + } + + public bool CanReleaseHold(Entity holder, EntityUid target, bool quiet = false) + { + if (!_activeHolderQuery.TryComp(holder, out var activeHolder) || + activeHolder.Target == null) + { + return false; + } + + if (activeHolder.Target != target) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-already-holding-other"), holder); + + return false; + } + + return true; + } + + public bool CanToggleHold( + Entity holder, + EntityUid target, + bool quiet = false, + bool ignoreHandAvailability = false, + bool checkAttempt = false) + { + if (holder.Owner == target) + return false; + + if (!CanStartHold(holder, quiet)) + return false; + + if (!_holdableQuery.TryComp(target, out var holdable)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-not-holdable", ("target", target)), holder); + + return false; + } + + if (!_whitelist.CheckBoth(target, holder.Comp.HoldableBlacklist, holder.Comp.HoldableWhitelist)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-invalid", ("target", target)), holder); + + return false; + } + + if (!_whitelist.CheckBoth(holder, holdable.HolderBlacklist, holdable.HolderWhitelist)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-invalid", ("target", target)), holder); + + return false; + } + + if (!_moverQuery.HasComp(holder)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-invalid", ("target", target)), holder); + + return false; + } + + if (!_moverQuery.HasComp(target)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-invalid", ("target", target)), holder); + + return false; + } + + if (!_physicsQuery.TryComp(target, out var targetPhysics)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-invalid", ("target", target)), holder); + + return false; + } + + if (targetPhysics.BodyType == BodyType.Static) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-invalid", ("target", target)), holder); + + return false; + } + + if (!_container.IsInSameOrNoContainer(holder.Owner, target)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-invalid", ("target", target)), holder); + + return false; + } + + if (TryComp(target, out _)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-immune", ("target", target)), holder); + + return false; + } + + var requiredHolderHandCount = GetRequiredHolderHandCount(holdable); + if (!ignoreHandAvailability && !HasAvailableHolderHands(holder, requiredHolderHandCount)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-holder-no-free-hand", ("target", target)), holder); + + return false; + } + + var range = holdable.HoldRange; + if (_activeHoldableQuery.HasComp(target)) + { + if (_activeHoldableFullHoldStateQuery.HasComp(target)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-fully-held", ("target", target)), holder); + + return false; + } + } + + if (!_interaction.InRangeUnobstructed(holder.Owner, target, range)) + { + if (!quiet) + _popup.PopupClient(Loc.GetString("scp-hold-target-too-far", ("target", target)), holder); + + return false; + } + + if (checkAttempt) + { + if (!CanPassPullAttempt(holder.Owner, target)) + return false; + + if (!CanPassHoldAttempt(holder, target)) + return false; + } + + return true; + } + + protected static int GetRequiredHolderHandCount(ScpHoldableComponent holdable) + { + return Math.Max(1, holdable.HolderHandsRequired); + } + + protected bool TryGetRequiredHolderHandCount(EntityUid targetUid, out int requiredHolderHandCount) + { + if (_holdableQuery.TryComp(targetUid, out var holdable)) + { + requiredHolderHandCount = GetRequiredHolderHandCount(holdable); + return true; + } + + requiredHolderHandCount = 0; + return false; + } + + public bool TryBreakOut(Entity held, bool viaMovement) + { + if (IsBreakoutBlockedByCuffs(held)) + return false; + + return _activeHoldableFullHoldStateQuery.HasComp(held) + ? TryStartFullBreakout(held, viaMovement) + : TrySoftBreakOut(held, viaMovement); + } + + public bool TryForceBreakOut(Entity held, bool viaMovement = false, bool applyImmunity = false) + { + if (!Resolve(held, ref held.Comp, false)) + return false; + + BreakOut(held!, viaMovement, applyImmunity); + return true; + } + + private void SyncHolderState(Entity holder) + { + SyncHolderHandBlocker(holder); + } + + private bool TrySoftBreakOut(Entity held, bool viaMovement) + { + if (_timing.CurTime < held.Comp.SoftEscapeAvailableAt) + return false; + + if (!viaMovement) + _popup.PopupClient(Loc.GetString("scp-hold-breakout-start"), held); + + BreakOut(held, viaMovement, applyImmunity: false); + return true; + } + + private bool TryStartFullBreakout(Entity held, bool viaMovement) + { + if (!_activeHoldableFullHoldStateQuery.TryComp(held, out var fullHeld)) + return false; + + if (!TryGetHeldHoldable(held, out var holdable)) + return false; + + if (fullHeld.StartedAt == TimeSpan.Zero) + { + _popup.PopupClient(Loc.GetString("scp-hold-breakout-not-ready"), held); + return false; + } + + var breakoutAvailableAt = fullHeld.StartedAt + holdable.FullHoldDelay; + if (_timing.CurTime < breakoutAvailableAt) + { + var remaining = breakoutAvailableAt - _timing.CurTime; + var remainingSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + _popup.PopupClient(Loc.GetString("scp-hold-breakout-too-early", ("seconds", remainingSeconds)), held); + return false; + } + + if (_breakoutAttemptQuery.HasComp(held)) + return true; + + var doAfter = new DoAfterArgs( + EntityManager, + held, + holdable.FullBreakoutDuration, + new ScpHoldBreakoutDoAfterEvent(viaMovement), + held, + target: held) + { + BreakOnMove = true, + BreakOnDamage = true, + NeedHand = false, + Hidden = false, + }; + + if (!_doAfter.TryStartDoAfter(doAfter, out var id)) + return false; + + StartBreakoutAttempt(held, id.Value); + + _popup.PopupClient(Loc.GetString("scp-hold-breakout-start"), held); + return true; + } + + private bool CanStartHold(Entity holder, bool quiet = false) + { + if (!IsHoldCoolingDown(holder, out var remaining)) + return true; + + if (!quiet) + { + var remainingSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + _popup.PopupClient(Loc.GetString("scp-hold-holder-action-on-cooldown", ("seconds", remainingSeconds)), holder); + } + + return false; + } + + private bool IsHoldCoolingDown(Entity holder, out TimeSpan remaining) + { + remaining = TimeSpan.Zero; + + if (holder.Comp.HoldAvailableAt is not { } availableAt || availableAt <= _timing.CurTime) + return false; + + remaining = availableAt - _timing.CurTime; + return true; + } + + private void StartHoldCooldown(Entity holder) + { + SetHoldAvailableAt(holder, _timing.CurTime + holder.Comp.HoldActionCooldown); + } + + private void ApplyFullBreakoutHolderCooldown(EntityUid holderUid) + { + if (!_holderConfigQuery.TryComp(holderUid, out var hold)) + return; + + var cooldownEnd = _timing.CurTime + hold.HoldActionCooldown * 2; + if (hold.HoldAvailableAt != null && hold.HoldAvailableAt.Value >= cooldownEnd) + return; + + SetHoldAvailableAt((holderUid, hold), cooldownEnd); + } + + private void SetHoldAvailableAt(Entity holder, TimeSpan? holdAvailableAt) + { + if (holder.Comp.HoldAvailableAt == holdAvailableAt) + return; + + var previousHoldAvailableAt = holder.Comp.HoldAvailableAt; + holder.Comp.HoldAvailableAt = holdAvailableAt; + + if (previousHoldAvailableAt == null || holdAvailableAt == null) + { + Dirty(holder); + return; + } + + DirtyField(holder, holder.Comp, nameof(ScpHolderComponent.HoldAvailableAt)); + } + + private bool CanPassHoldAttempt(EntityUid holderUid, EntityUid targetUid) + { + var attempt = new ScpHoldAttemptEvent(holderUid, targetUid); + RaiseLocalEvent(targetUid, ref attempt); + RaiseLocalEvent(holderUid, ref attempt); + return !attempt.Cancelled; + } + + private void BreakOut(Entity held, bool viaMovement, bool applyImmunity) + { + var ev = new ScpHoldBreakoutEvent(viaMovement, _activeHoldableFullHoldStateQuery.HasComp(held), applyImmunity); + RaiseLocalEvent(held, ref ev); + ClearHoldState(held, applyImmunity); + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.BreakoutAttempt.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.BreakoutAttempt.cs new file mode 100644 index 00000000000..4c9c8ad0378 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.BreakoutAttempt.cs @@ -0,0 +1,162 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared._Scp.Other.WorldAlert; +using Content.Shared.DoAfter; +using Content.Shared.Movement.Events; +using Content.Shared.Movement.Systems; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + /* + * Breakout-attempt query cache, event routing, semantic state, and do-after handle tracking. + */ + + [Dependency] private readonly WorldAlertSystem _worldAlert = default!; + + private EntityQuery _breakoutAttemptQuery; + + private void InitializeBreakoutAttemptQueries() + { + _breakoutAttemptQuery = GetEntityQuery(); + } + + private void InitializeBreakoutAttemptEvents() + { + SubscribeLocalEvent(OnBreakoutAlert); + SubscribeLocalEvent(OnBreakoutDoAfter); + SubscribeLocalEvent(OnHeldMoveInput); + SubscribeLocalEvent(OnBreakoutAttemptStartup); + SubscribeLocalEvent(OnBreakoutAttemptShutdown); + } + + private void StartBreakoutAttempt(EntityUid uid, DoAfterId doAfterId) + { + _breakoutDoAfterIds[uid] = doAfterId; + EnsureComp(uid); + } + + private void EndBreakoutAttempt(EntityUid uid, bool cancelDoAfter) + { + var hadAttempt = _breakoutAttemptQuery.HasComp(uid); + var hadDoAfter = _breakoutDoAfterIds.Remove(uid, out var doAfterId); + + if (hadAttempt) + RemComp(uid); + + if (cancelDoAfter && hadDoAfter) + CancelBreakoutAttemptDoAfter(doAfterId); + } + + private void CancelBreakoutAttemptDoAfter(DoAfterId doAfterId) + { + if (!_doAfter.IsRunning(doAfterId)) + return; + + _doAfter.Cancel(doAfterId); + } + + private void OnBreakoutAlert(Entity ent, ref ScpHoldBreakoutAlertEvent args) + { + if (args.Handled) + return; + + if (TryRedirectBreakoutAlertToUncuff(ent, args.User)) + { + args.Handled = true; + return; + } + + args.Handled = TryBreakOut(ent, viaMovement: false); + } + + private void OnBreakoutDoAfter(Entity ent, ref ScpHoldBreakoutDoAfterEvent args) + { + EndBreakoutAttempt(ent, cancelDoAfter: false); + + if (args.Handled) + return; + + if (args.Cancelled) + { + _popup.PopupClient(Loc.GetString("scp-hold-breakout-interrupted"), ent); + return; + } + + if (IsBreakoutBlockedByCuffs(ent)) + return; + + BreakOut(ent, args.ViaMovement, applyImmunity: true); + args.Handled = true; + } + + private void OnBreakoutAttemptStartup(Entity ent, ref ComponentStartup args) + { + if (!_activeHoldableQuery.TryComp(ent, out var held)) + return; + + ShowBreakoutAttemptFeedback((ent, held)); + } + + private void OnBreakoutAttemptShutdown(Entity ent, ref ComponentShutdown args) + { + if (!_breakoutDoAfterIds.Remove(ent, out var doAfterId)) + return; + + CancelBreakoutAttemptDoAfter(doAfterId); + } + + private void OnHeldMoveInput(Entity ent, ref MoveInputEvent args) + { + if (!IsBreakoutMovementPress(args)) + return; + + if (IsBreakoutBlockedByCuffs(ent)) + return; + + TryBreakOut(ent, viaMovement: true); + } + + private static bool IsBreakoutMovementPress(MoveInputEvent args) + { + if (!args.State) + return false; + + var pressedButton = args.Dir switch + { + Direction.East => MoveButtons.Right, + Direction.North => MoveButtons.Up, + Direction.West => MoveButtons.Left, + Direction.South => MoveButtons.Down, + _ => MoveButtons.None, + }; + + if (pressedButton == MoveButtons.None) + return false; + + return (args.OldMovement & pressedButton) == MoveButtons.None; + } + + private void ShowBreakoutAttemptFeedback(Entity held) + { + if (!_timing.IsFirstTimePredicted) + return; + + if (!TryComp(held, out var holdable)) + return; + + foreach (var holderUid in held.Comp.Holders) + { + if (!_activeHolderQuery.TryComp(holderUid, out var holder)) + continue; + + if (holder.Target != held) + continue; + + var settings = Comp(holderUid).BreakoutAttemptAlertSettings; + _worldAlert.TrySpawnAlert(holderUid, settings); + } + + _worldAlert.TrySpawnAlert(held, holdable.BreakoutAttemptAlertSettings); + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.BreakoutRestrictions.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.BreakoutRestrictions.cs new file mode 100644 index 00000000000..8b6b9674901 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.BreakoutRestrictions.cs @@ -0,0 +1,24 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared.Cuffs; +using Content.Shared.Cuffs.Components; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + [Dependency] private readonly SharedCuffableSystem _cuffable = default!; + + private bool IsBreakoutBlockedByCuffs(EntityUid heldUid) + { + return TryComp(heldUid, out var cuffable) && !cuffable.CanStillInteract; + } + + private bool TryRedirectBreakoutAlertToUncuff(Entity held, EntityUid user) + { + if (!TryComp(held, out var cuffable) || cuffable.CanStillInteract) + return false; + + _cuffable.TryUncuff((held.Owner, cuffable), user); + return true; + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.CursorMove.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.CursorMove.cs new file mode 100644 index 00000000000..9e0692d15e4 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.CursorMove.cs @@ -0,0 +1,372 @@ +using System.Numerics; +using Content.Shared._Scp.Holding.Components; +using Robust.Shared.Map; +using Robust.Shared.Physics.Components; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + /* + * Cursor-move input validation, per-holder cursor intent, and cursor-based movement helpers. + */ + + private const float CursorMoveCancelMovementDistance = 0.005f; + + private void InitializeCursorMoveEvents() + { + SubscribeLocalEvent(OnHolderMove); + } + + public bool TryMoveHeldToCursor(EntityUid holderUid, EntityCoordinates cursorCoords) + { + if (!_activeHolderQuery.TryComp(holderUid, out var activeHolder)) + return false; + + if (activeHolder.Target == null) + return false; + + if (!CanMoveHeldToCursor(holderUid, cursorCoords, out var targetCoords)) + return false; + + SetHolderCursorMoveState((holderUid, activeHolder), targetCoords, active: true); + return true; + } + + private bool CanMoveHeldToCursor( + EntityUid holderUid, + EntityCoordinates cursorCoords, + out EntityCoordinates targetCoords) + { + targetCoords = EntityCoordinates.Invalid; + + if (!_activeHolderQuery.TryComp(holderUid, out var holder)) + return false; + + if (holder.Target is not { } heldUid) + return false; + + if (!_activeHoldableQuery.TryComp(heldUid, out var heldComponent)) + return false; + + if (!heldComponent.Holders.Contains(holderUid)) + return false; + + var held = (heldUid, heldComponent); + + if (!TryGetHeldHoldable(held, out var holdable)) + return false; + + if (!_container.IsInSameOrNoContainer(holderUid, heldUid)) + return false; + + var desiredSoftDragDistance = GetDesiredSoftDragDistance(holdable); + var maintenanceRange = GetHoldMaintenanceRange(holdable, desiredSoftDragDistance); + + if (!_interaction.InRangeUnobstructed(holderUid, heldUid, maintenanceRange)) + return false; + + return TryNormalizeHeldCursorMoveTargetCoordinates(holderUid, cursorCoords, out targetCoords); + } + + private bool TryGetHolderCursorDesiredVelocity( + Entity holder, + Entity held, + ScpHoldableComponent holdable, + float maintenanceRange, + PhysicsComponent heldPhysics, + out Vector2 desiredVelocity) + { + desiredVelocity = Vector2.Zero; + + if (!TryGetValidatedHolderCursorMoveState(holder, held, maintenanceRange, out var targetCoordinates)) + return false; + + var holderCoords = _transform.GetMapCoordinates(holder); + var targetCoords = _transform.ToMapCoordinates(targetCoordinates); + var heldCoords = _transform.GetMapCoordinates(held); + + if (targetCoords.MapId != heldCoords.MapId) + { + ClearHolderCursorMoveState(holder); + return false; + } + + var correction = targetCoords.Position - heldCoords.Position; + var correctionDistance = correction.Length(); + + if (!holder.Comp.CursorMoveActive && correctionDistance > holdable.SoftDragSettleTolerance) + { + holder.Comp.CursorMoveActive = true; + DirtyField(holder, holder.Comp, nameof(ActiveScpHolderComponent.CursorMoveActive)); + } + + if (correctionDistance <= holdable.SoftDragSettleTolerance) + { + if (holder.Comp.CursorMoveActive) + { + holder.Comp.CursorMoveActive = false; + DirtyField(holder, holder.Comp, nameof(ActiveScpHolderComponent.CursorMoveActive)); + } + + return true; + } + + desiredVelocity = GetHolderCursorCorrectionVelocity( + holderCoords.Position, + heldCoords.Position, + targetCoords.Position, + holdable); + + if (desiredVelocity == Vector2.Zero) + return true; + + var correctionDirection = Vector2.Normalize(desiredVelocity); + var relativeVelocity = heldPhysics.LinearVelocity; + var awaySpeed = MathF.Max(0f, -Vector2.Dot(relativeVelocity, correctionDirection)); + if (awaySpeed > 0f) + desiredVelocity += correctionDirection * awaySpeed * holdable.SoftDragAwayVelocityStrength; + + desiredVelocity = ApplyCursorMoveSpeedModifier(desiredVelocity, holdable); + return true; + } + + private bool TryGetValidatedHolderCursorMoveState( + Entity holder, + Entity held, + float maintenanceRange, + out EntityCoordinates targetCoordinates) + { + targetCoordinates = EntityCoordinates.Invalid; + + if (holder.Comp.Target != held) + { + ClearHolderCursorMoveState(holder); + return false; + } + + if (!held.Comp.Holders.Contains(holder.Owner)) + { + ClearHolderCursorMoveState(holder); + return false; + } + + if (!holder.Comp.CursorTargetCoordinates.IsValid(EntityManager)) + return false; + + if (!_container.IsInSameOrNoContainer(holder.Owner, held.Owner)) + { + ClearHolderCursorMoveState(holder); + return false; + } + + if (!TryClampHeldCursorMoveTargetCoordinates( + holder.Owner, + holder.Comp.CursorTargetCoordinates, + maintenanceRange, + out targetCoordinates)) + { + ClearHolderCursorMoveState(holder); + return false; + } + + return true; + } + + private Vector2 GetHolderCursorCorrectionVelocity( + Vector2 holderPosition, + Vector2 heldPosition, + Vector2 targetPosition, + ScpHoldableComponent holdable) + { + var correction = targetPosition - heldPosition; + var correctionDistance = correction.Length(); + if (correctionDistance <= holdable.SoftDragSettleTolerance) + return Vector2.Zero; + + var currentOffset = heldPosition - holderPosition; + var desiredOffset = targetPosition - holderPosition; + var currentDistance = currentOffset.Length(); + var desiredDistance = desiredOffset.Length(); + + if (currentDistance <= holdable.SoftDragSettleTolerance || + desiredDistance <= holdable.SoftDragSettleTolerance) + { + return GetDirectCursorCorrectionVelocity(correction, correctionDistance, holdable); + } + + var catchUpTime = GetSoftDragCatchUpTime(holdable); + var currentDirection = currentOffset / currentDistance; + var desiredDirection = desiredOffset / desiredDistance; + var cross = currentDirection.X * desiredDirection.Y - currentDirection.Y * desiredDirection.X; + var dot = Math.Clamp(Vector2.Dot(currentDirection, desiredDirection), -1f, 1f); + var angleDelta = MathF.Atan2(cross, dot); + + var tangentDirection = cross >= 0f + ? new Vector2(-currentDirection.Y, currentDirection.X) + : new Vector2(currentDirection.Y, -currentDirection.X); + + var tangentialSpeed = Math.Min( + MathF.Abs(angleDelta) * MathF.Max(currentDistance, desiredDistance) / catchUpTime, + holdable.SoftDragMaximumCorrectionSpeed); + + var radialSpeed = Math.Clamp( + (desiredDistance - currentDistance) / catchUpTime, + -holdable.SoftDragMaximumCorrectionSpeed, + holdable.SoftDragMaximumCorrectionSpeed); + + var correctionVelocity = tangentDirection * tangentialSpeed + currentDirection * radialSpeed; + var maximumSpeedSquared = holdable.SoftDragMaximumCorrectionSpeed * holdable.SoftDragMaximumCorrectionSpeed; + if (correctionVelocity.LengthSquared() > maximumSpeedSquared) + correctionVelocity = Vector2.Normalize(correctionVelocity) * holdable.SoftDragMaximumCorrectionSpeed; + + return correctionVelocity; + } + + private Vector2 GetDirectCursorCorrectionVelocity( + Vector2 correction, + float correctionDistance, + ScpHoldableComponent holdable) + { + if (correctionDistance <= 0f) + return Vector2.Zero; + + var correctionDirection = correction / correctionDistance; + var correctionSpeed = Math.Min( + correctionDistance / GetSoftDragCatchUpTime(holdable), + holdable.SoftDragMaximumCorrectionSpeed); + + return correctionDirection * correctionSpeed; + } + + private static Vector2 ApplyCursorMoveSpeedModifier(Vector2 desiredVelocity, ScpHoldableComponent holdable) + { + if (desiredVelocity == Vector2.Zero) + return desiredVelocity; + + var speedModifier = Math.Max(0f, holdable.CursorMoveSpeedModifier ?? holdable.HolderSprintModifier); + if (MathF.Abs(speedModifier - 1f) <= 0.0001f) + return desiredVelocity; + + return desiredVelocity * speedModifier; + } + + private bool TryNormalizeHeldCursorMoveTargetCoordinates( + EntityUid holderUid, + EntityCoordinates cursorCoords, + out EntityCoordinates normalizedCoords) + { + normalizedCoords = EntityCoordinates.Invalid; + + if (!cursorCoords.IsValid(EntityManager)) + return false; + + var holderCoords = _transform.GetMapCoordinates(holderUid); + var cursorMapCoords = _transform.ToMapCoordinates(cursorCoords); + + if (holderCoords.MapId != cursorMapCoords.MapId) + return false; + + normalizedCoords = _transform.ToCoordinates(cursorMapCoords); + return normalizedCoords.IsValid(EntityManager); + } + + private bool TryClampHeldCursorMoveTargetCoordinates( + EntityUid holderUid, + EntityCoordinates cursorCoords, + float maintenanceRange, + out EntityCoordinates clampedCoords) + { + clampedCoords = EntityCoordinates.Invalid; + + if (!TryNormalizeHeldCursorMoveTargetCoordinates(holderUid, cursorCoords, out var normalizedCoords)) + return false; + + var holderCoords = _transform.GetMapCoordinates(holderUid); + var cursorMapCoords = _transform.ToMapCoordinates(normalizedCoords); + var offset = cursorMapCoords.Position - holderCoords.Position; + var distance = offset.Length(); + var clampedPosition = cursorMapCoords.Position; + + if (distance > maintenanceRange && distance > 0f) + clampedPosition = holderCoords.Position + offset / distance * maintenanceRange; + + clampedCoords = _transform.ToCoordinates(new MapCoordinates(clampedPosition, holderCoords.MapId)); + return clampedCoords.IsValid(EntityManager); + } + + private void SetHolderCursorMoveState( + Entity holder, + EntityCoordinates targetCoordinates, + bool active) + { + var targetChanged = holder.Comp.CursorTargetCoordinates != targetCoordinates; + var activeChanged = holder.Comp.CursorMoveActive != active; + if (!targetChanged && !activeChanged) + return; + + if (targetChanged) + { + holder.Comp.CursorTargetCoordinates = targetCoordinates; + DirtyField(holder, holder.Comp, nameof(ActiveScpHolderComponent.CursorTargetCoordinates)); + } + + if (activeChanged) + { + holder.Comp.CursorMoveActive = active; + DirtyField(holder, holder.Comp, nameof(ActiveScpHolderComponent.CursorMoveActive)); + } + } + + private void ClearHolderCursorMoveState(EntityUid holderUid) + { + if (_activeHolderQuery.TryComp(holderUid, out var holder)) + ClearHolderCursorMoveState((holderUid, holder)); + } + + private void ClearHolderCursorMoveState(Entity holder) + { + var targetChanged = holder.Comp.CursorTargetCoordinates != EntityCoordinates.Invalid; + var activeChanged = holder.Comp.CursorMoveActive; + if (!targetChanged && !activeChanged) + return; + + if (targetChanged) + { + holder.Comp.CursorTargetCoordinates = EntityCoordinates.Invalid; + DirtyField(holder, holder.Comp, nameof(ActiveScpHolderComponent.CursorTargetCoordinates)); + } + + if (activeChanged) + { + holder.Comp.CursorMoveActive = false; + DirtyField(holder, holder.Comp, nameof(ActiveScpHolderComponent.CursorMoveActive)); + } + } + + private void ClearHeldCursorMoveStates(Entity held) + { + foreach (var holderUid in held.Comp.Holders) + { + ClearHolderCursorMoveState(holderUid); + } + } + + private void OnHolderMove(Entity ent, ref MoveEvent args) + { + if (_timing.ApplyingState) + return; + + if (ent.Comp.Target == null || ent.Comp.CursorTargetCoordinates == EntityCoordinates.Invalid) + return; + + if (args.NewPosition.EntityId == args.OldPosition.EntityId && + (args.NewPosition.Position - args.OldPosition.Position).LengthSquared() < + CursorMoveCancelMovementDistance * CursorMoveCancelMovementDistance) + { + return; + } + + ClearHolderCursorMoveState(ent); + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Drag.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Drag.cs new file mode 100644 index 00000000000..539321da0ee --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Drag.cs @@ -0,0 +1,214 @@ +using System.Numerics; +using Content.Shared._Scp.Holding.Components; +using Content.Shared.Interaction; +using Content.Shared.Movement.Systems; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Events; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + /* + * Drag-local dependencies, aggregate holder movement, and helper calculations. + */ + + [Dependency] private readonly SharedTransformSystem _transform = default!; + + private EntityQuery _physicsQuery; + + private void InitializeDragQueries() + { + _physicsQuery = GetEntityQuery(); + } + + private void InitializeDragEvents() + { + SubscribeLocalEvent(OnHeldAttemptMobCollide); + SubscribeLocalEvent(OnHeldAttemptMobTargetCollide); + SubscribeLocalEvent(OnHeldPreventCollide); + SubscribeLocalEvent(OnHolderPreventCollide); + } + + private void UpdateSoftDrag( + Entity held, + ScpHoldableComponent holdable, + float maintenanceRange, + float desiredDistance) + { + if (!_physicsQuery.TryComp(held, out var heldPhysics)) + return; + + var aggregateDesiredVelocity = Vector2.Zero; + var contributingHolderCount = 0; + + foreach (var holderUid in held.Comp.Holders) + { + if (!_activeHolderQuery.TryComp(holderUid, out var holder)) + continue; + + if (holder.Target != held) + continue; + + Vector2 desiredVelocity; + if (!TryGetHolderCursorDesiredVelocity((holderUid, holder), held, holdable, maintenanceRange, heldPhysics, out desiredVelocity) && + !TryGetHolderMovementDesiredVelocity((holderUid, holder), held, holdable, maintenanceRange, desiredDistance, heldPhysics, out desiredVelocity)) + { + continue; + } + + aggregateDesiredVelocity += desiredVelocity; + contributingHolderCount++; + } + + if (contributingHolderCount == 0) + { + ZeroHeldVelocity(held); + return; + } + + ApplyHeldVelocity(held, aggregateDesiredVelocity / contributingHolderCount, heldPhysics, holdable); + } + + private bool TryGetHolderMovementDesiredVelocity( + Entity holder, + Entity held, + ScpHoldableComponent holdable, + float maintenanceRange, + float desiredDistance, + PhysicsComponent heldPhysics, + out Vector2 desiredVelocity) + { + desiredVelocity = Vector2.Zero; + + if (!_container.IsInSameOrNoContainer(holder.Owner, held.Owner)) + return false; + + if (!_interaction.InRangeUnobstructed(holder.Owner, held.Owner, maintenanceRange)) + return false; + + var holderCoords = _transform.GetMapCoordinates(holder.Owner); + var heldCoords = _transform.GetMapCoordinates(held.Owner); + + if (holderCoords.MapId != heldCoords.MapId) + return false; + + var offset = heldCoords.Position - holderCoords.Position; + var distance = offset.Length(); + var holderVelocity = _physicsQuery.TryComp(holder, out var holderPhysics) + ? holderPhysics.LinearVelocity + : Vector2.Zero; + var velocityDirectionThresholdSquared = holdable.SoftDragVelocityDirectionThreshold * holdable.SoftDragVelocityDirectionThreshold; + var direction = GetSoftDragDirection(holder.Owner, holdable, holderVelocity, offset, distance, velocityDirectionThresholdSquared); + var desiredPosition = holderCoords.Position + direction * desiredDistance; + var correction = desiredPosition - heldCoords.Position; + var correctionDistance = correction.Length(); + + if (correctionDistance <= holdable.SoftDragSettleTolerance) + { + desiredVelocity = holderVelocity.LengthSquared() > velocityDirectionThresholdSquared + ? holderVelocity + : Vector2.Zero; + return true; + } + + var correctionDirection = correction / correctionDistance; + var correctionSpeed = Math.Min(correctionDistance / GetSoftDragCatchUpTime(holdable), holdable.SoftDragMaximumCorrectionSpeed); + desiredVelocity = holderVelocity + correctionDirection * correctionSpeed; + + var relativeVelocity = heldPhysics.LinearVelocity - holderVelocity; + var awaySpeed = MathF.Max(0f, -Vector2.Dot(relativeVelocity, correctionDirection)); + if (awaySpeed > 0f) + desiredVelocity += correctionDirection * awaySpeed * holdable.SoftDragAwayVelocityStrength; + + return true; + } + + private static float GetDesiredSoftDragDistance(ScpHoldableComponent holdable) + { + return GetBaseSoftDragDistance(holdable); + } + + private static float GetHoldMaintenanceRange(ScpHoldableComponent holdable, float desiredSoftDragDistance) + { + return MathF.Max(MathF.Max(holdable.HoldRange, SharedInteractionSystem.InteractionRange), desiredSoftDragDistance + holdable.SoftDragSnapTolerance); + } + + private static float GetBaseSoftDragDistance(ScpHoldableComponent holdable) + { + return Math.Clamp(holdable.HoldRange * holdable.SoftDragDistanceFactor, holdable.SoftDragMinimumDistance, holdable.SoftDragMaximumDistance); + } + + private float GetSoftDragCatchUpTime(ScpHoldableComponent holdable) + { + return MathF.Max((float)_timing.TickPeriod.TotalSeconds, holdable.SoftDragCatchUpTime); + } + + private Vector2 GetSoftDragDirection(EntityUid holderUid, ScpHoldableComponent holdable, Vector2 holderVelocity, Vector2 offset, float distance, float velocityDirectionThresholdSquared) + { + if (distance > holdable.SoftDragSnapTolerance) + return offset / distance; + + if (holderVelocity.LengthSquared() > velocityDirectionThresholdSquared) + return -Vector2.Normalize(holderVelocity); + + return Transform(holderUid).LocalRotation.ToWorldVec(); + } + + private void ApplyHeldVelocity(EntityUid uid, Vector2 desiredVelocity, PhysicsComponent physics, ScpHoldableComponent holdable) + { + if (Vector2.DistanceSquared(physics.LinearVelocity, desiredVelocity) > holdable.SoftDragVelocityTolerance * holdable.SoftDragVelocityTolerance) + _physics.SetLinearVelocity(uid, desiredVelocity, body: physics); + + if (!MathHelper.CloseTo(physics.AngularVelocity, 0f)) + _physics.SetAngularVelocity(uid, 0f, body: physics); + } + + private void ZeroHeldVelocity(EntityUid uid) + { + if (!_physicsQuery.TryComp(uid, out var physics)) + return; + + if (physics.LinearVelocity == Vector2.Zero && MathHelper.CloseTo(physics.AngularVelocity, 0f)) + return; + + _physics.SetLinearVelocity(uid, Vector2.Zero, body: physics); + _physics.SetAngularVelocity(uid, 0f, body: physics); + } + + private static void OnHeldAttemptMobCollide(Entity ent, ref AttemptMobCollideEvent args) + { + args.Cancelled = true; + } + + private static void OnHeldAttemptMobTargetCollide(Entity ent, ref AttemptMobTargetCollideEvent args) + { + args.Cancelled = true; + } + + private void OnHeldPreventCollide(Entity ent, ref PreventCollideEvent args) + { + if (args.Cancelled) + return; + + if (_activeHolderQuery.TryComp(args.OtherEntity, out var holder) && + holder.Target == ent) + { + args.Cancelled = true; + } + } + + private void OnHolderPreventCollide(Entity ent, ref PreventCollideEvent args) + { + if (args.Cancelled) + return; + + if (ent.Comp.Target == null) + return; + + if (ent.Comp.Target != args.OtherEntity) + return; + + args.Cancelled = true; + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Hands.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Hands.cs new file mode 100644 index 00000000000..ea3c893903f --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Hands.cs @@ -0,0 +1,403 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared._Scp.Helpers; +using Content.Shared.Hands; +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Interaction.Components; +using Content.Shared.Inventory.VirtualItem; +using Content.Shared.Throwing; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + /* + * Hand-local dependencies, caches, placeholders, virtual blockers, and held-status visuals. + */ + + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly SharedVirtualItemSystem _virtualItem = default!; + + private readonly List _placeholderIcons = []; + private readonly HashSet _holdersSuppressingVirtualItemSync = []; + private EntityQuery _handsQuery; + private EntityQuery _virtualItemQuery; + private EntityQuery _heldHandBlockerQuery; + private EntityQuery _holdHandBlockerQuery; + private EntityQuery _unremoveableQuery; + + private void InitializeHandQueries() + { + _handsQuery = GetEntityQuery(); + _virtualItemQuery = GetEntityQuery(); + _heldHandBlockerQuery = GetEntityQuery(); + _holdHandBlockerQuery = GetEntityQuery(); + _unremoveableQuery = GetEntityQuery(); + } + + private void InitializeHandEvents() + { + SubscribeLocalEvent(OnHolderBeforeThrow); + SubscribeLocalEvent(OnHolderHandsModified); + SubscribeLocalEvent(OnHolderVirtualItemDeleted); + SubscribeLocalEvent(OnHolderBlockerGettingDropped); + } + + protected void SyncPlaceholderHands(Entity held) + { + if (!_handsQuery.TryComp(held, out var hands)) + return; + + if (!_activeHoldableFullHoldStateQuery.HasComp(held)) + { + DeleteHeldHandBlockers(held); + return; + } + + CollectPlaceholderIconHolders(held); + + if (_placeholderIcons.Count == 0) + { + DeleteHeldHandBlockers(held); + return; + } + + var heldHands = (held, hands); + DropHeldItemsForPlaceholders(heldHands); + DeleteInvalidHeldHandBlockers(heldHands); + EnsureHeldHandBlockers(heldHands); + } + + private void CollectPlaceholderIconHolders(Entity held) + { + _placeholderIcons.Clear(); + + foreach (var holderUid in held.Comp.Holders) + { + if (!_activeHolderQuery.TryComp(holderUid, out var holder)) + continue; + + if (holder.Target != held) + continue; + + _placeholderIcons.Add(holderUid); + } + } + + private void DropHeldItemsForPlaceholders(Entity held) + { + foreach (var hand in _hands.EnumerateHands(held)) + { + if (!_hands.TryGetHeldItem(held, hand, out var heldItem)) + continue; + + if (_unremoveableQuery.HasComp(heldItem.Value)) + continue; + + _hands.DoDrop(held, hand, doDropInteraction: true); + } + } + + private void DeleteInvalidHeldHandBlockers(Entity held) + { + using var virtualBlockersToDelete = ListPoolEntity.Rent(); + + foreach (var heldItem in _hands.EnumerateHeld(held)) + { + if (!_heldHandBlockerQuery.HasComp(heldItem)) + continue; + + if (!_virtualItemQuery.TryComp(heldItem, out var virtualItem)) + continue; + + if (!IsValidHeldHandBlocker(virtualItem)) + virtualBlockersToDelete.Value.Add((heldItem, virtualItem)); + } + + foreach (var virtualItem in virtualBlockersToDelete.Value) + { + _virtualItem.DeleteVirtualItem(virtualItem, held); + } + } + + private bool IsValidHeldHandBlocker(VirtualItemComponent virtualItem) + { + return _placeholderIcons.Contains(virtualItem.BlockingEntity); + } + + private void EnsureHeldHandBlockers(Entity held) + { + var iconIndex = 0; + while (_hands.TryGetEmptyHand(held, out var emptyHand)) + { + var holderUid = _placeholderIcons[iconIndex % _placeholderIcons.Count]; + if (!TryPickupHeldHandBlockerVirtualItem(holderUid, held, emptyHand)) + break; + + iconIndex++; + } + } + + private void SyncHolderHandBlocker(Entity holder) + { + using var virtualBlockersToDelete = ListPoolEntity.Rent(); + var target = holder.Comp.Target; + var validBlockerCount = 0; + var requiredHolderHandCount = 0; + + if (target != null + && _activeHoldableQuery.HasComp(target) + && TryGetRequiredHolderHandCount(target.Value, out var resolvedRequiredHolderHandCount)) + { + requiredHolderHandCount = resolvedRequiredHolderHandCount; + } + + foreach (var heldItem in _hands.EnumerateHeld(holder.Owner)) + { + if (!_virtualItemQuery.TryComp(heldItem, out var virtualItem)) + continue; + + var ownedBlocker = _holdHandBlockerQuery.HasComp(heldItem); + var matchesCurrentTarget = target != null + && virtualItem.BlockingEntity == target.Value; + + if (ownedBlocker && matchesCurrentTarget) + { + if (validBlockerCount < requiredHolderHandCount) + { + validBlockerCount++; + RemComp(heldItem); + continue; + } + } + + if (ownedBlocker) + virtualBlockersToDelete.Value.Add((heldItem, virtualItem)); + } + + foreach (var virtualItem in virtualBlockersToDelete.Value) + { + RemoveHolderHandBlocker(holder, virtualItem); + } + + if (target == null) + return; + + if (!_handsQuery.TryComp(holder, out var hands)) + { + ReleaseHolderContribution(holder, target.Value, clearIfEmpty: true); + return; + } + + var holderHands = (holder, hands); + + while (validBlockerCount < requiredHolderHandCount) + { + if (!_hands.TryGetEmptyHand(holderHands, out var emptyHand)) + break; + + if (!TryPickupHolderHandBlockerVirtualItem(target.Value, holderHands, emptyHand)) + break; + validBlockerCount++; + } + + validBlockerCount = CountOwnedHolderHandBlockers(holder, target.Value); + if (validBlockerCount < requiredHolderHandCount) + ReleaseHolderContribution(holder, target.Value, clearIfEmpty: true); + } + + private bool HasAvailableHolderHands(EntityUid holderUid, int requiredHandCount) + { + return _handsQuery.TryComp(holderUid, out var hands) + && _hands.CountFreeHands((holderUid, hands)) >= requiredHandCount; + } + + private int CountOwnedHolderHandBlockers(EntityUid holderUid, EntityUid targetUid) + { + var blockerCount = 0; + foreach (var heldItem in _hands.EnumerateHeld(holderUid)) + { + if (!_holdHandBlockerQuery.HasComp(heldItem)) + continue; + + if (!_virtualItemQuery.TryComp(heldItem, out var virtualItem)) + continue; + + if (virtualItem.BlockingEntity != targetUid) + continue; + + blockerCount++; + } + + return blockerCount; + } + + private bool TryPickupHeldHandBlockerVirtualItem( + EntityUid blockingEntity, + Entity user, + string handId) + { + if (!_virtualItem.TrySpawnVirtualItem(blockingEntity, user, out var virtualItemUid)) + return false; + + EnsureComp(virtualItemUid.Value); + EnsureComp(virtualItemUid.Value); + _hands.DoPickup(user, handId, virtualItemUid.Value, user.Comp); + + if (_hands.TryGetHeldItem(user, handId, out var heldItem) && heldItem == virtualItemUid.Value) + return true; + + DeleteFailedHandBlockerVirtualItem(virtualItemUid.Value); + return false; + } + + private bool TryPickupHolderHandBlockerVirtualItem( + EntityUid blockingEntity, + Entity user, + string handId) + { + if (!_virtualItem.TrySpawnVirtualItem(blockingEntity, user, out var virtualItemUid)) + return false; + + EnsureComp(virtualItemUid.Value); + _hands.DoPickup(user, handId, virtualItemUid.Value, user.Comp); + + if (_hands.TryGetHeldItem(user, handId, out var heldItem) && heldItem == virtualItemUid.Value) + return true; + + DeleteFailedHandBlockerVirtualItem(virtualItemUid.Value); + return false; + } + + private void DeleteFailedHandBlockerVirtualItem(EntityUid virtualItemUid) + { + QueueDel(virtualItemUid); + } + + private void RemoveHolderHandBlocker(EntityUid holderUid, Entity virtualItem) + { + var addedSuppression = _holdersSuppressingVirtualItemSync.Add(holderUid); + + if (_handsQuery.TryComp(holderUid, out var hands) + && _hands.IsHolding((holderUid, hands), virtualItem, out var hand)) + { + _hands.DoDrop((holderUid, hands), hand, doDropInteraction: false, log: false); + } + else + { + _virtualItem.DeleteVirtualItem(virtualItem, holderUid); + } + + if (addedSuppression) + _holdersSuppressingVirtualItemSync.Remove(holderUid); + } + + private void DeleteHolderHandBlockers(EntityUid holderUid) + { + using var virtualBlockersToDelete = ListPoolEntity.Rent(); + + foreach (var heldItem in _hands.EnumerateHeld(holderUid)) + { + if (!_holdHandBlockerQuery.HasComp(heldItem)) + continue; + + if (!_virtualItemQuery.TryComp(heldItem, out var virtualItem)) + continue; + + virtualBlockersToDelete.Value.Add((heldItem, virtualItem)); + } + + foreach (var virtualItem in virtualBlockersToDelete.Value) + { + RemoveHolderHandBlocker(holderUid, virtualItem); + } + } + + private void DeleteHeldHandBlockers(EntityUid heldUid) + { + using var virtualBlockersToDelete = ListPoolEntity.Rent(); + + foreach (var heldItem in _hands.EnumerateHeld(heldUid)) + { + if (_heldHandBlockerQuery.HasComp(heldItem) && + _virtualItemQuery.TryComp(heldItem, out var virtualItem)) + { + virtualBlockersToDelete.Value.Add((heldItem, virtualItem)); + } + } + + foreach (var virtualItem in virtualBlockersToDelete.Value) + { + _virtualItem.DeleteVirtualItem(virtualItem, heldUid); + } + } + + private void OnHolderBeforeThrow(Entity ent, ref BeforeThrowEvent args) + { + if (ent.Comp.Target == null) + return; + + if (!HasComp(args.ItemUid)) + return; + + if (!_virtualItemQuery.TryComp(args.ItemUid, out var virtualItem)) + return; + + if (virtualItem.BlockingEntity != ent.Comp.Target.Value) + return; + + ReleaseHolderContribution(ent, ent.Comp.Target.Value, clearIfEmpty: true); + args.Cancelled = true; + } + + private void OnHolderHandsModified(Entity ent, ref DidEquipHandEvent args) + { + if (ent.Comp.LifeStage > ComponentLifeStage.Running) + return; + + if (ent.Comp.Target == null) + return; + + if (!_activeHoldableQuery.HasComp(ent.Comp.Target.Value)) + return; + + SyncHolderState(ent); + } + + private void OnHolderBlockerGettingDropped(Entity ent, ref GettingDroppedAttemptEvent args) + { + if (!_virtualItemQuery.TryComp(ent, out var virtualItem)) + return; + + if (!TryComp(args.User, out var holder)) + return; + + if (!TryReleaseHold((args.User, holder), virtualItem.BlockingEntity)) + return; + + args.Cancelled = true; + } + + private void OnHolderVirtualItemDeleted(Entity ent, ref VirtualItemDeletedEvent args) + { + if (_timing.ApplyingState) + return; + + if (_holdersSuppressingVirtualItemSync.Contains(ent)) + return; + + if (TerminatingOrDeleted(ent)) + return; + + if (ent.Comp.Target == null || ent.Comp.Target != args.BlockingEntity) + return; + + if (!TryGetRequiredHolderHandCount(args.BlockingEntity, out var requiredHolderHandCount)) + return; + + if (CountOwnedHolderHandBlockers(ent, args.BlockingEntity) >= requiredHolderHandCount) + return; + + SyncHolderState(ent); + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Lifecycle.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Lifecycle.cs new file mode 100644 index 00000000000..863a1003241 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Lifecycle.cs @@ -0,0 +1,100 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared.Alert; +using Content.Shared.Movement.Events; +using Content.Shared.Movement.Systems; +using Robust.Shared.Prototypes; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + /* + * Held/holder lifecycle wiring and state refresh reactions. + */ + + private static readonly ProtoId HeldAlert = "ScpHoldGrabbed"; + + private void InitializeLifecycleEvents() + { + SubscribeLocalEvent(OnHeldStartup); + SubscribeLocalEvent(OnHeldShutdown); + SubscribeLocalEvent(OnHeldRemove); + SubscribeLocalEvent(OnFullHeldStartup); + SubscribeLocalEvent(OnFullHeldRemove); + SubscribeLocalEvent(OnFullHeldUpdateCanMove); + SubscribeLocalEvent(OnHolderStartup); + SubscribeLocalEvent(OnHolderShutdown); + SubscribeLocalEvent(OnHolderSlowdownRemove); + SubscribeLocalEvent(OnHolderSlowdownAfterState); + SubscribeLocalEvent(OnHolderSlowdownRefreshMoveSpeed); + } + + private void OnHeldStartup(Entity ent, ref ComponentStartup args) + { + _alerts.ShowAlert(ent.Owner, HeldAlert); + _statusEffects.TrySetStatusEffectDuration(ent, GrabbedStatusEffect); + ValidateAllActions(ent.Owner); + } + + private void OnHeldShutdown(Entity ent, ref ComponentShutdown args) + { + _alerts.ClearAlert(ent.Owner, HeldAlert); + _statusEffects.TryRemoveStatusEffect(ent, GrabbedStatusEffect); + OnHeldStateShutdown(ent); + } + + private void OnHeldRemove(Entity ent, ref ComponentRemove args) + { + ValidateAllActions(ent.Owner); + } + + private void OnFullHeldUpdateCanMove(Entity ent, ref UpdateCanMoveEvent args) + { + args.Cancel(); + } + + private void OnFullHeldStartup(Entity ent, ref ComponentStartup args) + { + if (_activeHoldableQuery.TryComp(ent, out var held)) + SyncPlaceholderHands((ent, held)); + + _actionBlocker.UpdateCanMove(ent); + } + + private void OnFullHeldRemove(Entity ent, ref ComponentRemove args) + { + DeleteHeldHandBlockers(ent); + _actionBlocker.UpdateCanMove(ent); + } + + private void OnHolderStartup(Entity ent, ref ComponentStartup args) + { + if (_timing.ApplyingState) + return; + + SyncHolderState(ent); + } + + private void OnHolderShutdown(Entity ent, ref ComponentShutdown args) + { + DeleteHolderHandBlockers(ent); + + if (!_timing.ApplyingState) + RemComp(ent); + } + + private void OnHolderSlowdownRemove(Entity ent, ref ComponentRemove args) + { + _movement.RefreshMovementSpeedModifiers(ent); + } + + private void OnHolderSlowdownAfterState(Entity ent, ref AfterAutoHandleStateEvent args) + { + _movement.RefreshMovementSpeedModifiers(ent); + } + + private void OnHolderSlowdownRefreshMoveSpeed(Entity ent, ref RefreshMovementSpeedModifiersEvent args) + { + args.ModifySpeed(ent.Comp.WalkModifier, ent.Comp.SprintModifier); + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.PullCompatibility.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.PullCompatibility.cs new file mode 100644 index 00000000000..0a7c335a416 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.PullCompatibility.cs @@ -0,0 +1,31 @@ +using Content.Shared.Buckle.Components; +using Content.Shared.Pulling.Events; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + public bool CanRedirectPullToScpHold(EntityUid pullerUid, EntityUid pullableUid) + { + return _holderConfigQuery.HasComp(pullerUid) && _holdableQuery.HasComp(pullableUid); + } + + private bool CanPassPullAttempt(EntityUid holderUid, EntityUid targetUid) + { + if (!_actionBlocker.CanInteract(holderUid, targetUid)) + return false; + + if (TryComp(targetUid, out var buckleComponent) && buckleComponent.Buckled) + return false; + + var beingPulledAttempt = new BeingPulledAttemptEvent(holderUid, targetUid); + RaiseLocalEvent(targetUid, beingPulledAttempt, true); + + if (beingPulledAttempt.Cancelled) + return false; + + var startPullAttempt = new StartPullAttemptEvent(holderUid, targetUid); + RaiseLocalEvent(holderUid, startPullAttempt, true); + return !startPullAttempt.Cancelled; + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Restrictions.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Restrictions.cs new file mode 100644 index 00000000000..9727bd66f98 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.Restrictions.cs @@ -0,0 +1,88 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared.Actions; +using Content.Shared.Actions.Components; +using Content.Shared.Actions.Events; +using Content.Shared.CombatMode; +using Content.Shared.Popups; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + [Dependency] private readonly SharedCombatModeSystem _combatMode = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly SharedActionsSystem _actions = default!; + + private void InitializeRestrictions() + { + SubscribeLocalEvent(OnRestrictionInit); + SubscribeLocalEvent(OnRestrictRemove); + SubscribeLocalEvent(OnHoldRestrictedActionAttempt); + } + + private void OnRestrictionInit(Entity ent, ref ComponentInit args) + { + ValidateActions(ent.AsNullable()); + } + + private void OnRestrictRemove(Entity ent, ref ComponentShutdown args) + { + ValidateActions(ent.AsNullable()); + } + + private void ValidateAllActions(Entity ent) + { + if (!Resolve(ent, ref ent.Comp, false)) + return; + + foreach (var action in ent.Comp.Actions) + { + ValidateActions(action); + } + } + + private void ValidateActions(Entity ent, ActionComponent? comp = null) + { + if (!Resolve(ent, ref ent.Comp, false)) + return; + + if (!Resolve(ent, ref comp)) + return; + + if (!comp.AttachedEntity.HasValue) + return; + + var shouldBeBlocked = IsHeldAtStage(comp.AttachedEntity.Value, ent.Comp.Stage); + if (shouldBeBlocked) + { + if (TryComp(comp.AttachedEntity.Value, out var combat)) + _combatMode.SetInCombatMode(comp.AttachedEntity.Value, false, combat); + } + + _actions.SetEnabled(ent.Owner, !shouldBeBlocked); + } + + private void OnHoldRestrictedActionAttempt(Entity ent, ref ActionAttemptEvent args) + { + if (args.Cancelled || !IsHeldAtStage(args.User, ent.Comp.Stage)) + return; + + _popup.PopupClient(Loc.GetString("scp-hold-action-restricted"), args.User, args.User); + args.Cancelled = true; + } + + public bool IsHeldAtStage(EntityUid uid, ScpHoldStage stage) + { + return _activeHoldableQuery.TryComp(uid, out var held) && IsHeldAtStage((uid, held), stage); + } + + private bool IsHeldAtStage(Entity held, ScpHoldStage stage) + { + return stage switch + { + ScpHoldStage.Soft => true, + ScpHoldStage.Full => _activeHoldableFullHoldStateQuery.HasComp(held), + _ => false, + }; + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.State.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.State.cs new file mode 100644 index 00000000000..f4e820b1240 --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.State.cs @@ -0,0 +1,295 @@ +using System.Diagnostics.CodeAnalysis; +using Content.Shared._Scp.Holding.Components; +using Content.Shared.Body.Part; +using Content.Shared.Body.Systems; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem +{ + /* + * State-local dependencies, caches, held lifecycle, holder membership, and full-hold transitions. + */ + + [Dependency] private readonly SharedBodySystem _body = default!; + + private readonly List _holdersToRemove = []; + private readonly List _holderCooldownsToApply = []; + + private EntityQuery _activeHoldableFullHoldStateQuery; + private EntityQuery _activeHoldableQuery; + private EntityQuery _holderConfigQuery; + private EntityQuery _activeHolderQuery; + private EntityQuery _activeHolderSlowdownStateQuery; + + private void InitializeStateQueries() + { + _activeHoldableFullHoldStateQuery = GetEntityQuery(); + _activeHoldableQuery = GetEntityQuery(); + _holderConfigQuery = GetEntityQuery(); + _activeHolderQuery = GetEntityQuery(); + _activeHolderSlowdownStateQuery = GetEntityQuery(); + } + + protected void UpdateHeld(Entity held) + { + if (!TryGetHeldHoldable(held, out var holdable)) + return; + + var desiredSoftDragDistance = GetDesiredSoftDragDistance(holdable); + var maintenanceRange = GetHoldMaintenanceRange(holdable, desiredSoftDragDistance); + + _holdersToRemove.Clear(); + + foreach (var holderUid in held.Comp.Holders) + { + if (ShouldReleaseHolder(holderUid, held, maintenanceRange)) + _holdersToRemove.Add(holderUid); + } + + foreach (var holderUid in _holdersToRemove) + { + ReleaseHolderContribution(holderUid, held, clearIfEmpty: false); + } + + if (!_activeHoldableQuery.TryComp(held, out var refreshed)) + return; + + held = (held, refreshed); + SyncHeldState(held); + + if (!_activeHoldableQuery.TryComp(held, out refreshed)) + return; + + held = (held, refreshed); + if (!TryGetHeldHoldable(held, out holdable)) + return; + + UpdateSoftDrag(held, holdable, maintenanceRange, desiredSoftDragDistance); + } + + private Entity EnsureHeldState(EntityUid target) + { + var created = !_activeHoldableQuery.TryComp(target, out var held); + held ??= EnsureComp(target); + + if (created) + held.SoftEscapeAvailableAt = _timing.CurTime; + + held.RequiredHolderCount = GetRequiredHolderCount(target); + Dirty(target, held); + return (target, held); + } + + private void AddHolderContribution(EntityUid holderUid, Entity held) + { + if (!held.Comp.Holders.Contains(holderUid)) + { + held.Comp.Holders.Add(holderUid); + Dirty(held); + } + + var holderCreated = !_activeHolderQuery.TryComp(holderUid, out var holder); + holder ??= EnsureComp(holderUid); + SetHolderTarget((holderUid, holder), held); + SyncHolderState((holderUid, holder)); + + if (holderCreated) + Dirty(holderUid, holder); + } + + protected void ReleaseHolderContribution(EntityUid holderUid, EntityUid targetUid, bool clearIfEmpty) + { + if (!_activeHoldableQuery.TryComp(targetUid, out var holdable)) + return; + + if (!holdable.Holders.Remove(holderUid)) + return; + + DirtyField(targetUid, holdable, nameof(ActiveScpHoldableComponent.Holders)); + RemComp(holderUid); + + var ent = (targetUid, holdable); + if (holdable.Holders.Count == 0) + { + if (clearIfEmpty) + ClearHoldState(ent, applyImmunity: false); + + return; + } + + SyncHeldState(ent); + } + + protected void SyncHeldState(Entity held) + { + if (!_activeHoldableQuery.TryComp(held, out var heldComp)) + return; + + held.Comp = heldComp; + + if (!TryGetHeldHoldable(held, out var holdable)) + return; + + var requiredHolderCount = GetRequiredHolderCount(held); + if (held.Comp.RequiredHolderCount != requiredHolderCount) + { + held.Comp.RequiredHolderCount = requiredHolderCount; + Dirty(held, held.Comp); + } + + if (held.Comp.Holders.Count == 0) + { + ClearHoldState(held, applyImmunity: false); + return; + } + + if (held.Comp.Holders.Count >= held.Comp.RequiredHolderCount) + EnterFullHold(held); + else + ExitFullHold(held); + + UpdateHolderSlowdowns(held, holdable); + SyncPlaceholderHands(held); + } + + private void EnterFullHold(Entity held) + { + var fullHeldCreated = !_activeHoldableFullHoldStateQuery.TryComp(held, out var fullHeld); + fullHeld ??= EnsureComp(held); + + if (fullHeldCreated) + { + fullHeld.StartedAt = _timing.CurTime; + Dirty(held, fullHeld); + } + } + + private void ExitFullHold(Entity held) + { + if (!_activeHoldableFullHoldStateQuery.HasComp(held)) + return; + + EndBreakoutAttempt(held, cancelDoAfter: true); + RemComp(held); + } + + private void ClearHoldState(Entity held, bool applyImmunity) + { + if (_activeHoldableQuery.TryComp(held, out var refreshed)) + held = (held, refreshed); + + ClearHeldCursorMoveStates(held); + EndBreakoutAttempt(held, cancelDoAfter: true); + + if (_activeHoldableFullHoldStateQuery.HasComp(held)) + RemComp(held); + + _holderCooldownsToApply.Clear(); + + foreach (var holderUid in held.Comp.Holders) + { + if (applyImmunity) + _holderCooldownsToApply.Add(holderUid); + + if (_activeHolderQuery.HasComp(holderUid)) + RemComp(holderUid); + else if (_activeHolderSlowdownStateQuery.HasComp(holderUid)) + RemComp(holderUid); + } + + held.Comp.Holders.Clear(); + + if (applyImmunity) + { + if (_holdableQuery.TryComp(held, out var holdable)) + { + if (!TryComp(held, out var immune)) + immune = EnsureComp(held); + + immune.ExpiresAt = _timing.CurTime + holdable.PostBreakoutImmunity; + Dirty(held, immune); + } + } + + foreach (var holderUid in _holderCooldownsToApply) + { + ApplyFullBreakoutHolderCooldown(holderUid); + } + + RemComp(held); + } + + private void UpdateHolderSlowdowns(Entity held, ScpHoldableComponent holdable) + { + foreach (var holderUid in held.Comp.Holders) + { + SetHolderSlowdown(holderUid, holdable.HolderWalkModifier, holdable.HolderSprintModifier); + } + } + + private void SetHolderSlowdown(EntityUid holderUid, float walkModifier, float sprintModifier) + { + var slowdownCreated = !_activeHolderSlowdownStateQuery.TryComp(holderUid, out var slowdown); + slowdown ??= EnsureComp(holderUid); + + if (!slowdownCreated && + MathHelper.CloseTo(slowdown.WalkModifier, walkModifier) && + MathHelper.CloseTo(slowdown.SprintModifier, sprintModifier)) + { + return; + } + + slowdown.WalkModifier = walkModifier; + slowdown.SprintModifier = sprintModifier; + Dirty(holderUid, slowdown); + _movement.RefreshMovementSpeedModifiers(holderUid); + } + + private int GetRequiredHolderCount(EntityUid target) + { + var handCount = 0; + foreach (var _ in _body.GetBodyChildrenOfType(target, BodyPartType.Hand)) + { + handCount++; + } + + return handCount; + } + + private bool TryGetHeldHoldable(Entity held, [NotNullWhen(true)] out ScpHoldableComponent? holdable) + { + if (_holdableQuery.TryComp(held, out holdable)) + return true; + + ClearHoldState(held, applyImmunity: false); + holdable = null; + return false; + } + + private bool ShouldReleaseHolder(EntityUid holderUid, Entity held, float maintenanceRange) + { + if (!_holderConfigQuery.HasComp(holderUid)) + return true; + + if (!_activeHolderQuery.TryComp(holderUid, out var holder)) + return true; + + if (holder.Target != held) + return true; + + if (!_container.IsInSameOrNoContainer(holderUid, held.Owner)) + return true; + + return !_interaction.InRangeUnobstructed(holderUid, held.Owner, maintenanceRange); + } + + private void SetHolderTarget(Entity holder, EntityUid? target) + { + if (holder.Comp.Target == target) + return; + + holder.Comp.Target = target; + DirtyField(holder, holder.Comp, nameof(ActiveScpHolderComponent.Target)); + } +} diff --git a/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.cs b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.cs new file mode 100644 index 00000000000..bff903de4ea --- /dev/null +++ b/Content.Shared/_Scp/Holding/Systems/SharedScpHoldingSystem.cs @@ -0,0 +1,90 @@ +using Content.Shared._Scp.Holding.Components; +using Content.Shared.ActionBlocker; +using Content.Shared.Alert; +using Content.Shared.DoAfter; +using Content.Shared.Interaction; +using Content.Shared.Movement.Systems; +using Content.Shared.StatusEffectNew; +using Content.Shared.Whitelist; +using Robust.Shared.Containers; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; + +namespace Content.Shared._Scp.Holding.Systems; + +public abstract partial class SharedScpHoldingSystem : EntitySystem +{ + /* + * Core lifecycle, dependencies, constants, and runtime caches. + */ + + [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; + [Dependency] private readonly AlertsSystem _alerts = default!; + [Dependency] private readonly SharedContainerSystem _container = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; + [Dependency] private readonly SharedInteractionSystem _interaction = default!; + [Dependency] private readonly MovementSpeedModifierSystem _movement = default!; + [Dependency] private readonly SharedPhysicsSystem _physics = default!; + [Dependency] private readonly StatusEffectsSystem _statusEffects = default!; + [Dependency] private readonly EntityWhitelistSystem _whitelist = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + private static readonly EntProtoId GrabbedStatusEffect = "StatusEffectScpHeld"; + private readonly Dictionary _breakoutDoAfterIds = []; + + public override void Initialize() + { + base.Initialize(); + + InitializeHoldQueries(); + InitializeBreakoutAttemptQueries(); + InitializeDragQueries(); + InitializeHandQueries(); + InitializeStateQueries(); + InitializeLifecycleEvents(); + InitializeBreakoutAttemptEvents(); + InitializeCursorMoveEvents(); + InitializeDragEvents(); + InitializeHandEvents(); + InitializeRestrictions(); + } + + public override void Shutdown() + { + base.Shutdown(); + _breakoutDoAfterIds.Clear(); + } + + public override void Update(float frameTime) + { + UpdateSharedState(); + UpdateHeldStates(); + } + + private void UpdateSharedState() + { + var immuneQuery = EntityQueryEnumerator(); + while (immuneQuery.MoveNext(out var uid, out var immune)) + { + if (_timing.CurTime >= immune.ExpiresAt) + RemCompDeferred(uid); + } + } + + private void UpdateAllHeldStates() + { + var heldQuery = EntityQueryEnumerator(); + while (heldQuery.MoveNext(out var uid, out var held)) + { + UpdateHeld((uid, held)); + } + } + + protected virtual void UpdateHeldStates() + { + UpdateAllHeldStates(); + } + + protected abstract void OnHeldStateShutdown(Entity held); +} diff --git a/Content.Shared/_Scp/Mobs/Systems/ScpRestrictionSystem.cs b/Content.Shared/_Scp/Mobs/Systems/ScpRestrictionSystem.cs index 42fc982470f..c834a2e3f9c 100644 --- a/Content.Shared/_Scp/Mobs/Systems/ScpRestrictionSystem.cs +++ b/Content.Shared/_Scp/Mobs/Systems/ScpRestrictionSystem.cs @@ -1,4 +1,5 @@ using Content.Shared._Scp.Mobs.Components; +using Content.Shared._Scp.Holding.Systems; using Content.Shared._Scp.ScpMask; using Content.Shared._Sunrise.Carrying; using Content.Shared.Actions.Events; @@ -19,6 +20,7 @@ namespace Content.Shared._Scp.Mobs.Systems; public sealed class ScpRestrictionSystem : EntitySystem { [Dependency] private readonly MobStateSystem _mobState = default!; + [Dependency] private readonly SharedScpHoldingSystem _scpHolding = default!; [Dependency] private readonly ScpMaskSystem _scpMask = default!; public override void Initialize() @@ -61,6 +63,11 @@ private static void OnPullAttempt(Entity ent, ref PullA private void OnBeingPulled(Entity ent, ref BeingPulledAttemptEvent args) { + // Fire added start - let SCP hold validation pass for restricted SCP hold targets. + if (_scpHolding.CanRedirectPullToScpHold(args.Puller, ent)) + return; + // Fire added end + var canBePulled = _mobState.IsIncapacitated(ent) || HasComp(ent) || _scpMask.HasScpMask(ent) diff --git a/Content.Shared/_Scp/Other/WorldAlert/WorldAlertSettings.cs b/Content.Shared/_Scp/Other/WorldAlert/WorldAlertSettings.cs new file mode 100644 index 00000000000..0fe3ba69311 --- /dev/null +++ b/Content.Shared/_Scp/Other/WorldAlert/WorldAlertSettings.cs @@ -0,0 +1,21 @@ +using Robust.Shared.Audio; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; + +namespace Content.Shared._Scp.Other.WorldAlert; + +[DataDefinition, Serializable, NetSerializable] +public partial record struct WorldAlertSettings +{ + [DataField] + public EntProtoId? Prototype; + + [DataField] + public SoundSpecifier? Sound; + + [DataField] + public bool DirectSound; + + [DataField] + public TimeSpan? Lifetime; +} diff --git a/Content.Shared/_Scp/Other/WorldAlert/WorldAlertSystem.cs b/Content.Shared/_Scp/Other/WorldAlert/WorldAlertSystem.cs new file mode 100644 index 00000000000..7fa4df51b54 --- /dev/null +++ b/Content.Shared/_Scp/Other/WorldAlert/WorldAlertSystem.cs @@ -0,0 +1,46 @@ +using Content.Shared.Coordinates; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Network; +using Robust.Shared.Spawners; + +namespace Content.Shared._Scp.Other.WorldAlert; + +public sealed class WorldAlertSystem : EntitySystem +{ + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly INetManager _net = default!; + + private const float DefaultLifetimeSeconds = 1f; + + public bool TrySpawnAlert(EntityUid target, WorldAlertSettings settings, EntityUid? soundReceiver = null) + { + if (settings.Prototype == null) + return false; + + var alert = PredictedSpawnAttachedTo(settings.Prototype, target.ToCoordinates()); + EnsureTimedDespawn(alert, settings.Lifetime); + + soundReceiver ??= target; + if (settings.DirectSound) + { + if (_net.IsServer) + _audio.PlayEntity(settings.Sound, target, soundReceiver.Value); + } + else + { + _audio.PlayPredicted(settings.Sound, target, soundReceiver.Value); + } + + return true; + } + + private void EnsureTimedDespawn(EntityUid uid, TimeSpan? lifetime) + { + var despawn = EnsureComp(uid); + + if (lifetime.HasValue) + despawn.Lifetime = (float)lifetime.Value.TotalSeconds; + else if (despawn.Lifetime < DefaultLifetimeSeconds) + despawn.Lifetime = DefaultLifetimeSeconds; + } +} diff --git a/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs b/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs index 4c4d65bd397..977d8e686e2 100644 --- a/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs +++ b/Content.Shared/_Scp/Scp096/Main/Components/Scp096Component.cs @@ -138,6 +138,34 @@ public sealed partial class Scp096Component : Component #endregion + #region Hold breakout + + /// + /// Урон, который получает каждый удерживающий скромника при успешном вырывании или смене состояния. + /// + [DataField] + public DamageSpecifier HoldBreakoutDamage = new() + { + DamageDict = new() + { + { "Blunt", 20 }, + }, + }; + + /// + /// Время паралича удерживающих после вырывания. + /// + [DataField] + public TimeSpan HoldBreakoutParalyzeTime = TimeSpan.FromSeconds(5f); + + /// + /// Сила отталкивания удерживающих после вырывания. + /// + [DataField] + public float HoldBreakoutImpulse = 40f; + + #endregion + #region Face skin rip /// diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Holding.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Holding.cs new file mode 100644 index 00000000000..d0d1d3046d7 --- /dev/null +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Holding.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; +using System.Numerics; +using Content.Shared._Scp.Holding; +using Content.Shared._Scp.Holding.Components; +using Content.Shared._Scp.Holding.Systems; +using Content.Shared._Scp.Scp096.Main.Components; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Systems; + +namespace Content.Shared._Scp.Scp096.Main.Systems; + +public abstract partial class SharedScp096System +{ + [Dependency] private readonly SharedScpHoldingSystem _holding = default!; + [Dependency] private readonly SharedPhysicsSystem _physics = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + private void InitializeHolding() + { + SubscribeLocalEvent(OnHoldAttempt); + SubscribeLocalEvent(OnHoldBreakout); + } + + private void OnHoldAttempt(Entity ent, ref ScpHoldAttemptEvent args) + { + if (IsInHoldRestrictedState(ent.Owner)) + args.Cancelled = true; + } + + private void OnHoldBreakout(Entity ent, ref ScpHoldBreakoutEvent args) + { + if (_timing.ApplyingState) + return; + + if (!args.WasFullHold && !IsInHoldRestrictedState(ent.Owner)) + return; + + if (!TryComp(ent.Owner, out var held)) + return; + + var scpPosition = _transform.GetWorldPosition(ent.Owner); + var holderCount = held.Holders.Count; + if (holderCount == 0) + return; + + var holders = new (EntityUid HolderUid, Vector2 Position)[holderCount]; + for (var i = 0; i < holderCount; i++) + { + var holderUid = held.Holders[i]; + holders[i] = (holderUid, _transform.GetWorldPosition(holderUid)); + } + + for (var i = 0; i < holderCount; i++) + { + var holder = holders[i]; + ApplyHoldBreakoutEffects(ent, holder.HolderUid, holder.Position, scpPosition, i, holderCount); + } + } + + protected bool IsInHoldRestrictedState(EntityUid uid) + { + return HasComp(uid) + || HasComp(uid) + || HasComp(uid); + } + + protected void TryBreakOutOfHold(EntityUid uid) + { + _holding.TryForceBreakOut((uid, (ActiveScpHoldableComponent?) null)); + } + + private void ApplyHoldBreakoutEffects( + Entity ent, + EntityUid holderUid, + Vector2 holderPosition, + Vector2 scpPosition, + int holderIndex, + int holderCount) + { + _damageable.TryChangeDamage(holderUid, ent.Comp.HoldBreakoutDamage, origin: ent.Owner); + _stun.TryUpdateParalyzeDuration(holderUid, ent.Comp.HoldBreakoutParalyzeTime); + + if (!TryComp(holderUid, out var physics)) + return; + + var direction = GetHoldBreakoutDirection(holderPosition, scpPosition, holderIndex, holderCount); + _physics.ApplyLinearImpulse(holderUid, direction * physics.Mass * ent.Comp.HoldBreakoutImpulse, body: physics); + } + + private static Vector2 GetHoldBreakoutDirection(Vector2 holderPosition, Vector2 scpPosition, int holderIndex, int holderCount) + { + Debug.Assert(holderCount > 0); + + var direction = holderPosition - scpPosition; + if (direction.LengthSquared() >= 0.001f) + return Vector2.Normalize(direction); + + var angle = 2f * MathF.PI * holderIndex / holderCount; + return new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + } +} diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs index 259cdc3714c..92a186ab269 100644 --- a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.Rage.cs @@ -37,6 +37,8 @@ private void InitializeRage() protected virtual void OnHeatingUpStart(Entity ent, ref ComponentStartup args) { + TryBreakOutOfHold(ent.Owner); + // Устанавливаем время окончания пред-агр состояния ent.Comp.RageHeatUpEnd = _timing.CurTime + ent.Comp.RageHeatUp; @@ -79,6 +81,8 @@ protected virtual void OnHeatingUpShutdown(Entity ent, ref ComponentStartup args) { + TryBreakOutOfHold(ent.Owner); + ent.Comp.RageStartTime = _timing.CurTime; Dirty(ent); diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs index 8a983f05ca5..834fb616a2f 100644 --- a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.WithoutFace.cs @@ -29,6 +29,8 @@ private void InitializeWithoutFace() /// private void OnWithoutFaceStartup(Entity ent, ref ComponentStartup args) { + TryBreakOutOfHold(ent.Owner); + var message = Loc.GetString("scp096-face-skin-rip-full", ("name", Identity.Name(ent, EntityManager))); _popup.PopupPredicted(message, ent, ent); diff --git a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs index 25535ee229e..4bbf164092a 100644 --- a/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs +++ b/Content.Shared/_Scp/Scp096/Main/Systems/SharedScp096System.cs @@ -99,6 +99,7 @@ public override void Initialize() InitializeTargets(); InitializeHands(); InitializeActions(); + InitializeHolding(); InitializeWithoutFace(); InitializeAppearance(); InitializeFace(); diff --git a/Resources/Locale/en-US/_strings/_scp/holding/holding.ftl b/Resources/Locale/en-US/_strings/_scp/holding/holding.ftl new file mode 100644 index 00000000000..db56ac7a2a9 --- /dev/null +++ b/Resources/Locale/en-US/_strings/_scp/holding/holding.ftl @@ -0,0 +1,15 @@ +scp-hold-already-holding-other = You are already holding someone else. +scp-hold-target-invalid = {CAPITALIZE(THE($target))} cannot be held. +scp-hold-target-not-holdable = {CAPITALIZE(THE($target))} cannot be grabbed with this hold. +scp-hold-target-immune = {CAPITALIZE(THE($target))} cannot be held yet. +scp-hold-target-fully-held = {CAPITALIZE(THE($target))} is already fully held. +scp-hold-target-too-far = You are too far away to hold {THE($target)}. +scp-hold-holder-no-free-hand = You need enough free hands to grab {THE($target)}. +scp-hold-holder-action-on-cooldown = You can grab again in {$seconds} s. +scp-hold-breakout-too-early = You can try to break free in {$seconds} s. +scp-hold-breakout-not-ready = You cannot start breaking free yet. +scp-hold-breakout-start = You start trying to break free. +scp-hold-breakout-interrupted = Your breakout attempt was interrupted. +scp-hold-action-restricted = You cannot do that while being held. +alerts-scp-held-name = Forcefully restrained +alerts-scp-held-desc = Someone is physically gripping you. Move or click this alert to try to break free. In a hard restraint you must endure the hold before the breakout starts. diff --git a/Resources/Locale/ru-RU/_strings/_scp/holding/holding.ftl b/Resources/Locale/ru-RU/_strings/_scp/holding/holding.ftl new file mode 100644 index 00000000000..2b20c3e082b --- /dev/null +++ b/Resources/Locale/ru-RU/_strings/_scp/holding/holding.ftl @@ -0,0 +1,16 @@ +scp-hold-already-holding-other = Вы уже удерживаете кого-то другого. +scp-hold-target-invalid = {CAPITALIZE(THE($target))} нельзя удержать. +scp-hold-target-not-holdable = {CAPITALIZE(THE($target))} нельзя схватить этим удержанием. +scp-hold-target-immune = {CAPITALIZE(THE($target))} пока нельзя удержать. +scp-hold-target-fully-held = {CAPITALIZE(THE($target))} уже полностью удерживается. +scp-hold-target-too-far = Вы слишком далеко, чтобы удерживать {THE($target)}. +scp-hold-holder-no-free-hand = Чтобы схватить {THE($target)}, нужно достаточно свободных рук. +scp-hold-holder-action-on-cooldown = Схватить снова можно через {$seconds} с. +scp-hold-breakout-too-early = Попытаться вырваться можно через {$seconds} с. +scp-hold-breakout-not-ready = Вы пока не можете начать вырываться. +scp-hold-breakout-start = Вы начинаете вырываться. +scp-hold-breakout-interrupted = Попытка вырваться была прервана. +scp-hold-action-restricted = Вы не можете сделать это, пока вас удерживают. +alerts-scp-held-name = Вас удерживают силой +alerts-scp-held-desc = Кто-то физически держит вас. + OOC: Двигайтесь или нажмите на этот статус-эффект, чтобы попытаться вырваться. В полном удержании сначала нужно выдержать захват. diff --git a/Resources/Prototypes/Actions/types.yml b/Resources/Prototypes/Actions/types.yml index fabea2350e3..89241e15629 100644 --- a/Resources/Prototypes/Actions/types.yml +++ b/Resources/Prototypes/Actions/types.yml @@ -261,6 +261,8 @@ priority: -100 - type: InstantAction event: !type:ToggleCombatActionEvent + - type: ScpHoldRestricted # Fire edit - block combat mode while held + stage: Soft - type: entity parent: ActionCombatModeToggle diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml index 6085f9ff1e1..cc0398a846f 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -278,6 +278,7 @@ - type: CritHeartbeat # Sunrise-End # Fire start + - type: ScpHoldable - type: FieldOfView - type: Blinkable - type: SpeakOnEyeStateChange diff --git a/Resources/Prototypes/_Scp/Actions/scp096.yml b/Resources/Prototypes/_Scp/Actions/scp096.yml index ffd94dcacbf..5d7a61cd7ab 100644 --- a/Resources/Prototypes/_Scp/Actions/scp096.yml +++ b/Resources/Prototypes/_Scp/Actions/scp096.yml @@ -11,6 +11,8 @@ state: scream - type: InstantAction event: !type:Scp096CryOutEvent + - type: ScpHoldRestricted + stage: Full - type: SafeTimeRestricted - type: entity @@ -26,6 +28,8 @@ state: rip - type: InstantAction event: !type:Scp096FaceSkinRipEvent + - type: ScpHoldRestricted + stage: Full - type: SafeTimeRestricted - type: entity @@ -42,3 +46,5 @@ state: sit - type: InstantAction event: !type:Scp096SitDownEvent + - type: ScpHoldRestricted + stage: Full diff --git a/Resources/Prototypes/_Scp/Alerts/holding.yml b/Resources/Prototypes/_Scp/Alerts/holding.yml new file mode 100644 index 00000000000..895d0dbe0f8 --- /dev/null +++ b/Resources/Prototypes/_Scp/Alerts/holding.yml @@ -0,0 +1,8 @@ +- type: alert + id: ScpHoldGrabbed + icons: + - sprite: /Textures/Objects/Misc/handcuffs.rsi + state: handcuff + name: alerts-scp-held-name + description: alerts-scp-held-desc + clickEvent: !type:ScpHoldBreakoutAlertEvent diff --git a/Resources/Prototypes/_Scp/Entities/Effects/world_alerts.yml b/Resources/Prototypes/_Scp/Entities/Effects/world_alerts.yml new file mode 100644 index 00000000000..80b7b58d5cf --- /dev/null +++ b/Resources/Prototypes/_Scp/Entities/Effects/world_alerts.yml @@ -0,0 +1,25 @@ +- type: entity + id: BaseWorldAlert + abstract: true + categories: [ HideSpawnMenu ] + components: + - type: Sprite + drawdepth: Effects + offset: 0, 1 + noRot: true + - type: Transform + noRot: true + - type: TimedDespawn + lifetime: 0.8 + - type: Tag + tags: + - HideContextMenu + +- type: entity + id: WorldAlertHandcuffs + parent: BaseWorldAlert + categories: [ HideSpawnMenu ] + components: + - type: Sprite + sprite: Objects/Misc/handcuffs.rsi + state: handcuff diff --git a/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml b/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml index edbdfe09adb..6b9dc5318b9 100644 --- a/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml +++ b/Resources/Prototypes/_Scp/Entities/Mobs/Player/Scp/Main/scp096.yml @@ -115,6 +115,10 @@ - type: Hands showInHands: false disableExplosionRecursion: true + - type: ScpHoldable + holderHandsRequired: 2 + holderWalkModifier: 0.25 + holderSprintModifier: 0.25 - type: GhostPanelAntagonistMarker name: ghost-panel-antagonist-scp-name description: ghost-panel-antagonist-scp-description diff --git a/Resources/Prototypes/_Scp/Entities/StatusEffects/holding.yml b/Resources/Prototypes/_Scp/Entities/StatusEffects/holding.yml new file mode 100644 index 00000000000..91dc1b60c62 --- /dev/null +++ b/Resources/Prototypes/_Scp/Entities/StatusEffects/holding.yml @@ -0,0 +1,5 @@ +- type: entity + parent: MobStatusEffectDebuff + id: StatusEffectScpHeld + categories: [ HideSpawnMenu ] + name: forcefully restrained diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml b/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml index bcea05af290..123f201d142 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/Administration/security_commander.yml @@ -33,6 +33,10 @@ class: B - type: AccessLevel level: Four + - type: ScpHolder + holdableWhitelist: + components: + - ClassDAppearance - type: ScpAnnounceOnSpawn text: head-announce-on-spawn channels: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml index 753d503f2d6..8ea1fcd0a32 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_commandant.yml @@ -28,6 +28,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: ScpAnnounceOnSpawn text: squad-commander-announce-on-spawn channels: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml index 10b8e31d5a5..6208db982ef 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/external_administrative_zone_officer.yml @@ -28,6 +28,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml index ad1027479c3..88b9d322534 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/field_medical_specialist.yml @@ -31,6 +31,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: Fear phobias: - Exoremophobia diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml index bcd4dc7d15c..cca447f6666 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/junior_external_administrative_zone_officer.yml @@ -27,6 +27,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml index e4353f351a1..7ebe0f1464a 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/CommandantSquad/senior_external_administrative_zone_officer.yml @@ -28,6 +28,13 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - HumanoidAppearance + holdableBlacklist: + components: + - Scp - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml index 00efb76ff5b..ed04e5fabcf 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d.yml @@ -18,6 +18,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml index ca349700658..57951816fc0 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_botanist.yml @@ -23,6 +23,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDBotanistGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml index af37b4abf9e..fb00bcd70b3 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_cook.yml @@ -23,6 +23,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDCookGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml index 2eb3e3f2278..0cd5dbdd3c6 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/LowAccessPersonnel/class_d_janitor.yml @@ -20,6 +20,15 @@ - ExternalAdministrativeZoneSecurityService includeJobName: true requiredGameRunLevel: InRound + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: startingGear id: ClassDJanitorGear diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml index ab5fe8465a1..6eac3a5a809 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_commandant.yml @@ -28,6 +28,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: ScpAnnounceOnSpawn text: squad-commander-announce-on-spawn channels: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml index c6ab3659cbd..ee9bb872ba4 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/heavy_containment_zone_officer.yml @@ -28,6 +28,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml index 99ad7d3e958..54065100cfe 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/junior_heavy_containment_zone_officer.yml @@ -27,6 +27,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: diff --git a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml index b5a6901a778..bfe0afb2589 100644 --- a/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml +++ b/Resources/Prototypes/_Scp/Roles/Jobs/SpecialPurposeSquad/senior_heavy_containment_zone_officer.yml @@ -28,6 +28,16 @@ class: C - type: AccessLevel level: Three + - type: ScpHolder + holdableWhitelist: + components: + - Scp096 + - ClassDAppearance + holdableBlacklist: + components: + - ActiveScp096Rage + - ActiveScp096HeatingUp + - ActiveScp096WithoutFace - type: SpeakOnEyeStateChange # переопределение моргания с другими параметрами для охраны и МОГа # Часть фраз уходит, так как более разговорная. А эти парни натренированы. phraseOnClose: