using Content.Shared.Coordinates.Helpers; using Content.Shared.Interaction; using Content.Shared.Maps; using Content.Shared.Movement.Components; using Content.Shared.Movement.Pulling.Components; using Content.Shared.Movement.Pulling.Events; using Content.Shared.Movement.Systems; using Robust.Shared.Audio.Systems; using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Physics; using Robust.Shared.Physics.Components; using Robust.Shared.Physics.Systems; using Robust.Shared.Timing; using System.Numerics; namespace Content.Shared._DV.Movement; public sealed class TileMovementSystem : EntitySystem { [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IMapManager _map = default!; [Dependency] private readonly INetManager _net = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedInteractionSystem _interaction = default!; [Dependency] private readonly SharedMoverController _mover = default!; [Dependency] private readonly SharedPhysicsSystem _physics = default!; [Dependency] private readonly SharedTransformSystem _transform = default!; private EntityQuery _query; private EntityQuery _fixturesQuery; private EntityQuery _moverQuery; private EntityQuery _mobMoverQuery; private EntityQuery _modifierQuery; private EntityQuery _noRotQuery; private EntityQuery _physicsQuery; private EntityQuery _pullerQuery; private EntityQuery _relayQuery; private HashSet _ticked = new(); public override void Initialize() { base.Initialize(); _query = GetEntityQuery(); _fixturesQuery = GetEntityQuery(); _moverQuery = GetEntityQuery(); _mobMoverQuery = GetEntityQuery(); _modifierQuery = GetEntityQuery(); _noRotQuery = GetEntityQuery(); _physicsQuery = GetEntityQuery(); _pullerQuery = GetEntityQuery(); _relayQuery = GetEntityQuery(); SubscribeLocalEvent(OnMapInit); SubscribeLocalEvent(OnPullStarted); SubscribeLocalEvent(OnPullStopped); } public override void Update(float frameTime) { base.Update(frameTime); _ticked.Clear(); } private void OnMapInit(Entity ent, ref MapInitEvent args) { if (GetTarget(ent) is not {} target) return; // when adding tile movement immediately move them to the tile center StartSlideTo(target, target.Comp4.LocalPosition); UpdateSlide(target); } private void OnPullStarted(Entity ent, ref PullStartedMessage args) { var target = args.PulledUid; if (ent.Owner != args.PullerUid || !_mobMoverQuery.HasComp(target)) return; // if you have tile movement and pull a mob, it gets tile movement too temporarily. if (EnsureComp(target, out var comp)) return; comp.Temporary = true; DirtyField(target, comp, nameof(TileMovementComponent.Temporary)); } private void OnPullStopped(Entity ent, ref PullStoppedMessage args) { // only remove temporary tile movement when no longer pulled if (!ent.Comp.Temporary || ent.Owner != args.PulledUid) return; ent.Comp.Temporary = false; RemCompDeferred(ent, ent.Comp); } private Entity? GetTarget(EntityUid player) { if (_relayQuery.TryComp(player, out var relay)) player = relay.RelayEntity; if (!_query.TryComp(player, out var comp) || !_moverQuery.TryComp(player, out var mover) || !_physicsQuery.TryComp(player, out var physics)) return null; return (player, mover, comp, physics, Transform(player)); } private TimeSpan CurrentTime => _physics.EffectiveCurTime ?? _timing.CurTime; public bool HasTileMovement(EntityUid? uid) { return _query.HasComp(uid); } /// /// Tries to process a tick of tile movement for a mover. /// /// The player moving a mob /// The movement target if not the player, i.e. a mech /// True if it was handled public bool TryTick( Entity player, EntityUid? relaySource, ContentTileDefinition? tileDef, bool weightless, float frameTime) { if (!_query.TryComp(player, out var comp)) return false; var ent = (player.Owner, player.Comp1, comp, player.Comp2, player.Comp3); // let client predict pulled movement so it looks good // this is needed since client only predicts its own movement if (_net.IsClient && _timing.IsFirstTimePredicted) RelayPulled(player, frameTime); var wasWeightless = comp.WasWeightlessLastTick; SetWeightless((player, comp), weightless); // no tiles in space... if (weightless || player.Comp2.BodyStatus != BodyStatus.OnGround) { EndSlide((player, comp, player.Comp2)); SetButtons((player, comp), MoveButtons.None); return false; } // For smoothness' sake, if we just arrived on a grid after pixel moving in space then start // a slide towards the center of the tile we're on. It just ends up feeling better this way. if (wasWeightless) { StartSlideTo(ent, player.Comp3.LocalPosition); SetButtons((player, comp), MoveButtons.None); UpdateSlide(ent); return true; } // If we're not moving or trying to move, apply friction to existing velocity and then stop. var buttons = StripWalk(player.Comp1.HeldMoveButtons); if (buttons == MoveButtons.None && !comp.SlideActive) { var velocity = player.Comp2.LinearVelocity; var moveSpeed = _modifierQuery.CompOrNull(player); var friction = GetEntityFriction(player.Comp1, moveSpeed, tileDef); var minSpeed = moveSpeed?.MinimumFrictionSpeed ?? MovementSpeedModifierComponent.DefaultMinimumFrictionSpeed; _mover.Friction(minSpeed, frameTime, friction, ref velocity); _physics.SetLinearVelocity(player, velocity, body: player.Comp2); _physics.SetAngularVelocity(player, 0, body: player.Comp2); return true; } // Otherwise, begin tile movement. // Set WorldRotation so that our character is facing the way we're walking. if (!_noRotQuery.HasComp(player)) Rotate((player, player.Comp1, comp, player.Comp3)); // Play step sound. TryPlaySound((player, player.Comp1, player.Comp3), relaySource, tileDef); // If we're sliding possibly end the slide or continue it if (comp.SlideActive) TryEndSlide(ent); // Start sliding otherwise else if (buttons != MoveButtons.None) StartSlide(ent); return true; } private void RelayPulled(EntityUid puller, float frameTime) { if (_pullerQuery.CompOrNull(puller)?.Pulling is not {} player) return; if (GetTarget(player) is not {} target) return; // don't stack overflow if there's a pull circle A -> B -> C -> A ... if (!_ticked.Add(player)) return; _mover.HandleMobMovement(target, frameTime); } public void SetWeightless(Entity player, bool weightless) { if (player.Comp.WasWeightlessLastTick == weightless) return; player.Comp.WasWeightlessLastTick = weightless; DirtyField(player, player.Comp, nameof(TileMovementComponent.WasWeightlessLastTick)); } public void SetButtons(Entity player, MoveButtons buttons) { if (player.Comp.CurrentSlideMoveButtons == buttons) return; player.Comp.CurrentSlideMoveButtons = buttons; DirtyField(player, player.Comp, nameof(TileMovementComponent.CurrentSlideMoveButtons)); } public void Rotate(Entity player) { if (!player.Comp2.SlideActive || player.Comp1.RelativeEntity is not {} rel) return; var relXform = Transform(rel); var delta = player.Comp2.Destination - player.Comp2.Origin; var worldRot = _transform.GetWorldRotation(relXform).RotateVec(delta).ToWorldAngle(); _transform.SetWorldRotation(player.Comp3, worldRot); } public void TryPlaySound( Entity player, EntityUid? relaySource, ContentTileDefinition? tileDef) { if (!_mobMoverQuery.TryComp(player, out var mobMover) || !_mover.TryGetSound(false, player, player.Comp1, mobMover, player.Comp2, out var sound, tileDef)) { return; } var soundModifier = player.Comp1.Sprinting ? 3.5f : 1.5f; var audioParams = sound.Params .WithVolume(sound.Params.Volume + soundModifier) .WithVariation(sound.Params.Variation ?? mobMover.FootstepVariation); _audio.PlayPredicted(sound, player, relaySource ?? player, audioParams); } public void TryEndSlide(Entity player) { var speed = GetEntityMoveSpeed(player, player.Comp1.Sprinting); var buttons = StripWalk(player.Comp1.HeldMoveButtons); if (!ShouldSlideEnd(buttons, player.Comp4, player.Comp2, speed)) { UpdateSlide(player); return; } // stop sliding now EndSlide((player, player.Comp2, player.Comp3)); SetButtons((player, player.Comp2), buttons); if (buttons == MoveButtons.None) { ForceSnapToTile((player, player.Comp3, player.Comp4)); return; } // if a button is still being held start sliding again immediately StartSlide(player); UpdateSlide(player); } public bool ShouldSlideEnd(MoveButtons buttons, TransformComponent xform, TileMovementComponent comp, float movementSpeed) { var minPressedTime = (1.05f / movementSpeed); // We need to stop the move once we are close enough. This isn't perfect, since it technically ends the move // 1 tick early in some cases. This is because there's a fundamental issue where because this is a physics-based // tile movement system, we sometimes find scenarios where on each tick of the physics system, the player is moved // back and forth across the destination in a loop. Thus, the tolerance needs to be set overly high so that it // reaches the distance once the physics body can move in a single tick. var destinationTolerance = movementSpeed * 0.01f; var reachedDestination = xform.LocalPosition.EqualsApprox(comp.Destination, destinationTolerance); var stoppedPressing = buttons != comp.CurrentSlideMoveButtons && (CurrentTime - comp.MovementKeyPressedAt) >= TimeSpan.FromSeconds(minPressedTime); return reachedDestination || stoppedPressing; } public void StartSlide(Entity player) { var buttons = player.Comp1.HeldMoveButtons; var offset = _mover.DirVecForButtons(buttons); offset = player.Comp1.TargetRelativeRotation.RotateVec(offset); StartSlideTo(player, player.Comp4.LocalPosition + offset); SetButtons((player, player.Comp2), StripWalk(buttons)); } // physics isnt used but it makes calling it a bit easier public void StartSlideTo( Entity player, Vector2 dest) { player.Comp2.Origin = player.Comp4.LocalPosition; player.Comp2.Destination = SnapCoordinatesToTile(dest); player.Comp2.MovementKeyPressedAt = CurrentTime; DirtyField(player, player.Comp2, nameof(TileMovementComponent.Origin)); DirtyField(player, player.Comp2, nameof(TileMovementComponent.Destination)); DirtyField(player, player.Comp2, nameof(TileMovementComponent.MovementKeyPressedAt)); // pull the pulled mob along if it currently has TileMovement if (_pullerQuery.CompOrNull(player)?.Pulling is not {} pulling || !_query.TryComp(pulling, out var pullingComp)) return; if (GetTarget(pulling) is not {} pullTarget) return; // already set, don't stack overflow for pull circles if (pullingComp.Destination.EqualsApprox(player.Comp2.Origin, 0.01f)) return; StartSlideTo(pullTarget, player.Comp2.Origin); UpdateSlide(pullTarget); } /// /// Forces the target entity's velocity based on where the player is moving to. /// public void UpdateSlide(Entity player) { var parentRot = _mover.GetParentGridAngle(player.Comp1); var speed = GetEntityMoveSpeed(player, player.Comp1.Sprinting); // Determine velocity based on movespeed, and rotate it so that it's in the right direction. var velocity = player.Comp2.Destination - player.Comp4.LocalPosition; velocity.Normalize(); velocity *= speed; velocity = parentRot.RotateVec(velocity); _physics.SetLinearVelocity(player, velocity, body: player.Comp3); _physics.SetAngularVelocity(player, 0, body: player.Comp3); } /// /// Kills the target entity's velocity and stops the current slide. /// public void EndSlide(Entity player) { if (!player.Comp1.SlideActive) return; player.Comp1.MovementKeyPressedAt = null; DirtyField(player, player.Comp1, nameof(TileMovementComponent.MovementKeyPressedAt)); _physics.SetLinearVelocity(player, Vector2.Zero, body: player.Comp2); _physics.SetAngularVelocity(player, 0, body: player.Comp2); } #region Helpers private float GetEntityMoveSpeed(EntityUid player, bool sprinting) { var moveSpeed = _modifierQuery.CompOrNull(player); return sprinting ? moveSpeed?.CurrentSprintSpeed ?? MovementSpeedModifierComponent.DefaultBaseSprintSpeed : moveSpeed?.CurrentWalkSpeed ?? MovementSpeedModifierComponent.DefaultBaseWalkSpeed; } /// /// Returns the given local coordinates snapped to the center of the tile it is currently on. /// /// Given coordinates to snap. /// The closest tile center to the input. private Vector2 SnapCoordinatesToTile(Vector2 input) { return new Vector2((int) Math.Floor(input.X) + 0.5f, (int) Math.Floor(input.Y) + 0.5f); } /// /// Instantly snaps/teleports an entity to the center of the tile it is currently standing on based on the /// given grid. Does not trigger collisions on the way there, but does trigger collisions after the snap. /// private void ForceSnapToTile(Entity target) { var coords = target.Comp2.Coordinates.SnapToGrid(EntityManager, _map); _transform.SetCoordinates(target, target.Comp2, coords); _physics.WakeBody(target, body: target.Comp1); } private float GetEntityFriction( InputMoverComponent mover, MovementSpeedModifierComponent? moveSpeed, ContentTileDefinition? tileDef) { if (StripWalk(mover.HeldMoveButtons) != MoveButtons.None || moveSpeed == null) { return tileDef?.MobFriction ?? moveSpeed?.Friction ?? MovementSpeedModifierComponent.DefaultFriction; } return tileDef?.Friction ?? moveSpeed.FrictionNoInput; } /// /// Sets the walk value on the given MoveButtons input to zero. /// private MoveButtons StripWalk(MoveButtons input) => input & ~MoveButtons.Walk; #endregion }