using Content.Shared.Alert; using Content.Shared.Buckle.Components; using Content.Shared.CCVar; using Content.Shared.Damage; using Content.Shared.Damage.Components; using Content.Shared.Database; using Content.Shared.DoAfter; using Content.Shared.Gravity; using Content.Shared.Hands.EntitySystems; using Content.Shared.Input; using Content.Shared.Movement.Events; using Content.Shared.Movement.Systems; using Content.Shared.Popups; using Content.Shared.Rejuvenate; using Content.Shared.Standing; using Robust.Shared.Audio; using Robust.Shared.Configuration; using Robust.Shared.Input.Binding; using Robust.Shared.Physics; using Robust.Shared.Physics.Systems; using Robust.Shared.Player; using Robust.Shared.Prototypes; namespace Content.Shared.Stunnable; /// /// This contains the knockdown logic for the stun system for organization purposes. /// public abstract partial class SharedStunSystem { private EntityQuery _crawlerQuery; [Dependency] private readonly EntityLookupSystem _entityLookup = default!; [Dependency] private readonly SharedHandsSystem _hands = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; [Dependency] private readonly SharedPhysicsSystem _physics = default!; [Dependency] private readonly StandingStateSystem _standingState = default!; [Dependency] private readonly IConfigurationManager _cfgManager = default!; public static readonly ProtoId KnockdownAlert = "Knockdown"; private void InitializeKnockdown() { _crawlerQuery = GetEntityQuery(); SubscribeLocalEvent(OnRejuvenate); // Startup and Shutdown SubscribeLocalEvent(OnKnockInit); SubscribeLocalEvent(OnKnockShutdown); // Action blockers SubscribeLocalEvent(OnBuckleAttempt); SubscribeLocalEvent(OnStandAttempt); // Updating movement a friction SubscribeLocalEvent(OnRefreshKnockedSpeed); SubscribeLocalEvent(OnRefreshFriction); SubscribeLocalEvent(OnKnockedTileFriction); // DoAfter event subscriptions SubscribeLocalEvent(OnStandDoAfter); // Crawling SubscribeLocalEvent(OnKnockdownRefresh); SubscribeLocalEvent(OnDamaged); SubscribeLocalEvent(OnWeightlessnessChanged); SubscribeLocalEvent(OnKnockdownAttempt); SubscribeLocalEvent(OnGetStandUpTime); // Handling Alternative Inputs SubscribeAllEvent(OnForceStandup); SubscribeLocalEvent(OnKnockedDownAlert); CommandBinds.Builder .Bind(ContentKeyFunctions.ToggleKnockdown, InputCmdHandler.FromDelegate(HandleToggleKnockdown, handle: false)) .Register(); } public override void Update(float frameTime) { base.Update(frameTime); var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var knockedDown)) { // If it's null then we don't want to stand up if (!knockedDown.AutoStand || knockedDown.DoAfterId.HasValue || knockedDown.NextUpdate > GameTiming.CurTime) continue; TryStanding(uid); } } private void OnRejuvenate(Entity entity, ref RejuvenateEvent args) { SetKnockdownTime(entity, GameTiming.CurTime); if (entity.Comp.AutoStand) RemComp(entity); } #region Startup and Shutdown private void OnKnockInit(Entity entity, ref ComponentInit args) { // Other systems should handle dropping held items... _standingState.Down(entity, true, false); RefreshKnockedMovement(entity); } private void OnKnockShutdown(Entity entity, ref ComponentShutdown args) { // This is jank but if we don't do this it'll still use the knockedDownComponent modifiers for friction because it hasn't been deleted quite yet. entity.Comp.FrictionModifier = 1f; entity.Comp.SpeedModifier = 1f; _standingState.Stand(entity); Alerts.ClearAlert(entity, KnockdownAlert); } #endregion #region API /// /// Sets the autostand property of a on an entity to true or false and dirties it. /// Defaults to false. /// /// Entity we want to edit the data field of. /// What we want to set the data field to. public void SetAutoStand(Entity entity, bool autoStand = false) { if (!Resolve(entity, ref entity.Comp, false)) return; entity.Comp.AutoStand = autoStand; DirtyField(entity, entity.Comp, nameof(KnockedDownComponent.AutoStand)); } /// /// Cancels the DoAfter of an entity with the who is trying to stand. /// /// Entity who we are canceling the DoAfter for. public void CancelKnockdownDoAfter(Entity entity) { if (!Resolve(entity, ref entity.Comp, false)) return; if (entity.Comp.DoAfterId == null) return; DoAfter.Cancel(entity.Owner, entity.Comp.DoAfterId.Value); entity.Comp.DoAfterId = null; DirtyField(entity, entity.Comp, nameof(KnockedDownComponent.DoAfterId)); } /// /// Updates the knockdown timer of a knocked down entity with a given inputted time, then dirties the time. /// /// Entity who's knockdown time we're updating. /// The time we're updating with. /// Whether we're resetting the timer or adding to the current timer. public void UpdateKnockdownTime(Entity entity, TimeSpan time, bool refresh = true) { if (refresh) RefreshKnockdownTime(entity, time); else AddKnockdownTime(entity, time); } /// /// Sets the next update datafield of an entity's to a specific time. /// /// Entity whose timer we're updating /// The exact time we're setting the next update to. public void SetKnockdownTime(Entity entity, TimeSpan time) { entity.Comp.NextUpdate = time; DirtyField(entity, entity.Comp, nameof(KnockedDownComponent.NextUpdate)); Alerts.ShowAlert(entity, KnockdownAlert, null, (GameTiming.CurTime, entity.Comp.NextUpdate)); } /// /// Refreshes the amount of time an entity is knocked down to the inputted time, if it is greater than /// the current time left. /// /// Entity whose timer we're updating /// The time we want them to be knocked down for. public void RefreshKnockdownTime(Entity entity, TimeSpan time) { if (!Resolve(entity, ref entity.Comp, false)) return; var knockedTime = GameTiming.CurTime + time; if (entity.Comp.NextUpdate < knockedTime) SetKnockdownTime((entity, entity.Comp), knockedTime); } /// /// Adds our inputted time to an entity's knocked down timer, or sets it to the given time if their timer has expired. /// /// Entity whose timer we're updating /// The time we want to add to their knocked down timer. public void AddKnockdownTime(Entity entity, TimeSpan time) { if (!Resolve(entity, ref entity.Comp, false)) return; if (entity.Comp.NextUpdate < GameTiming.CurTime) { SetKnockdownTime((entity, entity.Comp), GameTiming.CurTime + time); return; } entity.Comp.NextUpdate += time; DirtyField(entity, entity.Comp, nameof(KnockedDownComponent.NextUpdate)); Alerts.ShowAlert(entity, KnockdownAlert, null, (GameTiming.CurTime, entity.Comp.NextUpdate)); } #endregion #region Knockdown Logic private void HandleToggleKnockdown(ICommonSession? session) { if (session is not { } playerSession) return; if (playerSession.AttachedEntity is not { Valid: true } playerEnt || !Exists(playerEnt)) return; ToggleKnockdown(playerEnt); } /// /// Handles an entity trying to make itself fall down. /// /// Entity who is trying to fall down private void ToggleKnockdown(Entity entity) { // We resolve here instead of using TryCrawling to be extra sure someone without crawler can't stand up early. if (!Resolve(entity, ref entity.Comp1, false) || !_cfgManager.GetCVar(CCVars.MovementCrawling)) return; if (!Resolve(entity, ref entity.Comp2, false)) { TryKnockdown(entity.Owner, entity.Comp1.DefaultKnockedDuration, true, false, false); return; } var stand = !entity.Comp2.DoAfterId.HasValue; SetAutoStand((entity, entity.Comp2), stand); if (!stand || !TryStanding((entity, entity.Comp2))) CancelKnockdownDoAfter((entity, entity.Comp2)); } public bool TryStanding(Entity entity) { // If we aren't knocked down or can't be knocked down, then we did technically succeed in standing up if (!Resolve(entity, ref entity.Comp, false)) return true; if (!KnockdownOver((entity, entity.Comp))) return false; if (!_crawlerQuery.TryComp(entity, out var crawler) || !_cfgManager.GetCVar(CCVars.MovementCrawling)) { // If we can't crawl then just have us sit back up... // In case you're wondering, the KnockdownOverCheck, returns if we're able to move, so if next update is null. // An entity that can't crawl will stand up the next time they can move, which should prevent moving while knocked down. RemComp(entity); _adminLogger.Add(LogType.Stamina, LogImpact.Medium, $"{ToPrettyString(entity):user} has stood up from knockdown."); return true; } if (!TryStand((entity, entity.Comp))) return false; var ev = new GetStandUpTimeEvent(crawler.StandTime); RaiseLocalEvent(entity, ref ev); var doAfterArgs = new DoAfterArgs(EntityManager, entity, ev.DoAfterTime, new TryStandDoAfterEvent(), entity, entity) { BreakOnDamage = true, DamageThreshold = 5, CancelDuplicate = true, RequireCanInteract = false, BreakOnHandChange = true }; // If we try standing don't try standing again if (!DoAfter.TryStartDoAfter(doAfterArgs, out var doAfterId)) return false; entity.Comp.DoAfterId = doAfterId.Value.Index; DirtyField(entity, entity.Comp, nameof(KnockedDownComponent.DoAfterId)); return true; } public bool KnockdownOver(Entity entity) { if (entity.Comp.NextUpdate > GameTiming.CurTime) return false; return Blocker.CanMove(entity); } /// /// A variant of used when we're actually trying to stand. /// Main difference is this one affects autostand datafields and also displays popups. /// /// Entity we're checking /// Returns whether the entity is able to stand public bool TryStand(Entity entity) { if (!KnockdownOver(entity)) return false; var ev = new StandUpAttemptEvent(entity.Comp.AutoStand); RaiseLocalEvent(entity, ref ev); if (ev.Autostand != entity.Comp.AutoStand) SetAutoStand((entity.Owner, entity.Comp), ev.Autostand); if (ev.Message != null) { _popup.PopupClient(ev.Message.Value.Item1, entity, entity, ev.Message.Value.Item2); } return !ev.Cancelled; } /// /// Checks if an entity is able to stand, returns true if it can, returns false if it cannot. /// /// Entity we're checking /// Returns whether the entity is able to stand public bool CanStand(Entity entity) { if (!KnockdownOver(entity)) return false; var ev = new StandUpAttemptEvent(); RaiseLocalEvent(entity, ref ev); return !ev.Cancelled; } private bool StandingBlocked(Entity entity) { if (!TryStand(entity)) return true; if (!IntersectingStandingColliders(entity.Owner)) return false; _popup.PopupClient(Loc.GetString("knockdown-component-stand-no-room"), entity, entity, PopupType.SmallCaution); SetAutoStand(entity.Owner); return true; } private void OnForceStandup(ForceStandUpEvent msg, EntitySessionEventArgs args) { if (args.SenderSession.AttachedEntity is not {} user) return; ForceStandUp(user); } public void ForceStandUp(Entity entity) { if (!Resolve(entity, ref entity.Comp, false)) return; // That way if we fail to stand, the game will try to stand for us when we are able to SetAutoStand(entity, true); if (StandingBlocked((entity, entity.Comp))) return; if (!_hands.TryGetEmptyHand(entity.Owner, out _)) return; if (!TryForceStand(entity.Owner)) return; // If we have a DoAfter, cancel it CancelKnockdownDoAfter(entity); // Remove Component RemComp(entity); _adminLogger.Add(LogType.Stamina, LogImpact.Medium, $"{ToPrettyString(entity):user} has force stood up from knockdown."); } private void OnKnockedDownAlert(Entity entity, ref KnockedDownAlertEvent args) { if (args.Handled) return; // If we're already trying to stand, or we fail to stand try forcing it if (!TryStanding(entity.Owner)) ForceStandUp((entity.Owner, entity.Comp)); args.Handled = true; } private bool TryForceStand(Entity entity) { // Can't force stand if no Stamina. if (!Resolve(entity, ref entity.Comp, false)) return false; var ev = new TryForceStandEvent(entity.Comp.ForceStandStamina); RaiseLocalEvent(entity, ref ev); if (!Stamina.TryTakeStamina(entity, ev.Stamina, entity.Comp, visual: true)) { _popup.PopupClient(Loc.GetString("knockdown-component-pushup-failure"), entity, entity, PopupType.MediumCaution); return false; } _popup.PopupClient(Loc.GetString("knockdown-component-pushup-success"), entity, entity); _audio.PlayPredicted(entity.Comp.ForceStandSuccessSound, entity.Owner, entity.Owner, AudioParams.Default.WithVariation(0.025f).WithVolume(5f)); return true; } /// /// Checks if standing would cause us to collide with something and potentially get stuck. /// Returns true if we will collide with something, and false if we will not. /// private bool IntersectingStandingColliders(Entity entity) { if (!Resolve(entity, ref entity.Comp)) return false; var intersecting = _physics.GetEntitiesIntersectingBody(entity, StandingStateSystem.StandingCollisionLayer, false); if (intersecting.Count == 0) return false; var fixtureQuery = GetEntityQuery(); var xformQuery = GetEntityQuery(); var ourAABB = _entityLookup.GetAABBNoContainer(entity, entity.Comp.LocalPosition, entity.Comp.LocalRotation); foreach (var ent in intersecting) { if (!fixtureQuery.TryGetComponent(ent, out var fixtures)) continue; if (!xformQuery.TryComp(ent, out var xformComp)) continue; var xform = new Transform(xformComp.LocalPosition, xformComp.LocalRotation); foreach (var fixture in fixtures.Fixtures.Values) { if (!fixture.Hard || (fixture.CollisionMask & StandingStateSystem.StandingCollisionLayer) != StandingStateSystem.StandingCollisionLayer) continue; for (var i = 0; i < fixture.Shape.ChildCount; i++) { var intersection = fixture.Shape.ComputeAABB(xform, i).IntersectPercentage(ourAABB); if (intersection > 0.1f) return true; } } } return false; } #endregion #region Crawling private void OnDamaged(Entity entity, ref DamageChangedEvent args) { // We only want to extend our knockdown timer if it would've prevented us from standing up if (!args.InterruptsDoAfters || !args.DamageIncreased || args.DamageDelta == null || GameTiming.ApplyingState) return; if (args.DamageDelta.GetTotal() >= entity.Comp.KnockdownDamageThreshold) RefreshKnockdownTime(entity.Owner, entity.Comp.DefaultKnockedDuration); } private void OnKnockdownRefresh(Entity entity, ref KnockedDownRefreshEvent args) { args.FrictionModifier *= entity.Comp.FrictionModifier; args.SpeedModifier *= entity.Comp.SpeedModifier; } private void OnWeightlessnessChanged(Entity entity, ref WeightlessnessChangedEvent args) { // I probably don't need this check since weightless -> non-weightless you shouldn't be knocked down // But you never know. if (!args.Weightless) return; // Targeted moth attack CancelKnockdownDoAfter((entity, entity.Comp)); RemCompDeferred(entity); } private void OnKnockdownAttempt(Entity entity, ref KnockDownAttemptEvent args) { // Directed, targeted moth attack. if (entity.Comp.Weightless) args.Cancelled = true; } private void OnGetStandUpTime(Entity entity, ref GetStandUpTimeEvent args) { // Get up instantly if weightless if (entity.Comp.Weightless) args.DoAfterTime = TimeSpan.Zero; } #endregion #region Action Blockers private void OnStandAttempt(Entity entity, ref StandAttemptEvent args) { if (entity.Comp.LifeStage <= ComponentLifeStage.Running) args.Cancel(); } private void OnBuckleAttempt(Entity entity, ref BuckleAttemptEvent args) { if (args.User == entity && entity.Comp.NextUpdate > GameTiming.CurTime) args.Cancelled = true; } #endregion #region DoAfter private void OnStandDoAfter(Entity entity, ref TryStandDoAfterEvent args) { entity.Comp.DoAfterId = null; if (args.Cancelled || StandingBlocked(entity)) { DirtyField(entity, entity.Comp, nameof(KnockedDownComponent.DoAfterId)); return; } RemComp(entity); _adminLogger.Add(LogType.Stamina, LogImpact.Medium, $"{ToPrettyString(entity):user} has stood up from knockdown."); } #endregion #region Movement and Friction private void RefreshKnockedMovement(Entity ent) { var ev = new KnockedDownRefreshEvent(); RaiseLocalEvent(ent, ref ev); ent.Comp.SpeedModifier = ev.SpeedModifier; ent.Comp.FrictionModifier = ev.FrictionModifier; _movementSpeedModifier.RefreshMovementSpeedModifiers(ent); _movementSpeedModifier.RefreshFrictionModifiers(ent); } private void OnRefreshKnockedSpeed(Entity entity, ref RefreshMovementSpeedModifiersEvent args) { args.ModifySpeed(entity.Comp.SpeedModifier); } private void OnKnockedTileFriction(Entity entity, ref TileFrictionEvent args) { args.Modifier *= entity.Comp.FrictionModifier; } private void OnRefreshFriction(Entity entity, ref RefreshFrictionModifiersEvent args) { args.ModifyFriction(entity.Comp.FrictionModifier); args.ModifyAcceleration(entity.Comp.FrictionModifier); } #endregion }