using Content.Shared.CCVar; using Content.Shared.Follower.Components; using Content.Shared.Input; using Content.Shared.Movement.Components; using Content.Shared.Movement.Events; using Robust.Shared.GameStates; using Robust.Shared.Input; using Robust.Shared.Input.Binding; using Robust.Shared.Players; using Robust.Shared.Serialization; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Shared.Movement.Systems { /// /// Handles converting inputs into movement. /// public abstract partial class SharedMoverController { public bool CameraRotationLocked { get; set; } private void InitializeInput() { var moveUpCmdHandler = new MoverDirInputCmdHandler(this, Direction.North); var moveLeftCmdHandler = new MoverDirInputCmdHandler(this, Direction.West); var moveRightCmdHandler = new MoverDirInputCmdHandler(this, Direction.East); var moveDownCmdHandler = new MoverDirInputCmdHandler(this, Direction.South); CommandBinds.Builder .Bind(EngineKeyFunctions.MoveUp, moveUpCmdHandler) .Bind(EngineKeyFunctions.MoveLeft, moveLeftCmdHandler) .Bind(EngineKeyFunctions.MoveRight, moveRightCmdHandler) .Bind(EngineKeyFunctions.MoveDown, moveDownCmdHandler) .Bind(EngineKeyFunctions.Walk, new WalkInputCmdHandler(this)) .Bind(EngineKeyFunctions.CameraRotateLeft, new CameraRotateInputCmdHandler(this, Direction.East)) .Bind(EngineKeyFunctions.CameraRotateRight, new CameraRotateInputCmdHandler(this, Direction.West)) .Bind(EngineKeyFunctions.CameraReset, new CameraResetInputCmdHandler(this)) // TODO: Relay // Shuttle .Bind(ContentKeyFunctions.ShuttleStrafeUp, new ShuttleInputCmdHandler(this, ShuttleButtons.StrafeUp)) .Bind(ContentKeyFunctions.ShuttleStrafeLeft, new ShuttleInputCmdHandler(this, ShuttleButtons.StrafeLeft)) .Bind(ContentKeyFunctions.ShuttleStrafeRight, new ShuttleInputCmdHandler(this, ShuttleButtons.StrafeRight)) .Bind(ContentKeyFunctions.ShuttleStrafeDown, new ShuttleInputCmdHandler(this, ShuttleButtons.StrafeDown)) .Bind(ContentKeyFunctions.ShuttleRotateLeft, new ShuttleInputCmdHandler(this, ShuttleButtons.RotateLeft)) .Bind(ContentKeyFunctions.ShuttleRotateRight, new ShuttleInputCmdHandler(this, ShuttleButtons.RotateRight)) .Bind(ContentKeyFunctions.ShuttleBrake, new ShuttleInputCmdHandler(this, ShuttleButtons.Brake)) .Register(); SubscribeLocalEvent(OnInputInit); SubscribeLocalEvent(OnInputGetState); SubscribeLocalEvent(OnInputHandleState); SubscribeLocalEvent(OnInputParentChange); SubscribeLocalEvent(OnFollowedParentChange); _configManager.OnValueChanged(CCVars.CameraRotationLocked, SetCameraRotationLocked, true); _configManager.OnValueChanged(CCVars.GameDiagonalMovement, SetDiagonalMovement, true); } private void SetCameraRotationLocked(bool obj) { CameraRotationLocked = obj; } protected void SetMoveInput(InputMoverComponent component, MoveButtons buttons) { if (component.HeldMoveButtons == buttons) return; component.HeldMoveButtons = buttons; Dirty(component); } private void OnInputHandleState(EntityUid uid, InputMoverComponent component, ref ComponentHandleState args) { if (args.Current is not InputMoverComponentState state) return; component.HeldMoveButtons = state.Buttons; component.LastInputTick = GameTick.Zero; component.LastInputSubTick = 0; component.CanMove = state.CanMove; component.RelativeRotation = state.RelativeRotation; component.TargetRelativeRotation = state.TargetRelativeRotation; component.RelativeEntity = state.RelativeEntity; component.LerpTarget = state.LerpAccumulator; } private void OnInputGetState(EntityUid uid, InputMoverComponent component, ref ComponentGetState args) { args.State = new InputMoverComponentState( component.HeldMoveButtons, component.CanMove, component.RelativeRotation, component.TargetRelativeRotation, component.RelativeEntity, component.LerpTarget); } private void ShutdownInput() { CommandBinds.Unregister(); _configManager.UnsubValueChanged(CCVars.CameraRotationLocked, SetCameraRotationLocked); _configManager.UnsubValueChanged(CCVars.GameDiagonalMovement, SetDiagonalMovement); } public bool DiagonalMovementEnabled { get; private set; } private void SetDiagonalMovement(bool value) => DiagonalMovementEnabled = value; protected virtual void HandleShuttleInput(EntityUid uid, ShuttleButtons button, ushort subTick, bool state) {} public void RotateCamera(EntityUid uid, Angle angle) { if (CameraRotationLocked || !TryComp(uid, out var mover)) return; mover.TargetRelativeRotation += angle; Dirty(mover); } public void ResetCamera(EntityUid uid) { if (CameraRotationLocked || !TryComp(uid, out var mover)) { return; } // If we updated parent then cancel the accumulator and force it now. var xformQuery = GetEntityQuery(); if (!TryUpdateRelative(mover, xformQuery.GetComponent(uid), xformQuery) && mover.TargetRelativeRotation.Equals(Angle.Zero)) return; mover.LerpTarget = TimeSpan.Zero; mover.TargetRelativeRotation = Angle.Zero; Dirty(mover); } private bool TryUpdateRelative(InputMoverComponent mover, TransformComponent xform, EntityQuery xformQuery) { var relative = xform.GridUid; relative ??= xform.MapUid; // So essentially what we want: // 1. If we go from grid to map then preserve our rotation and continue as usual // 2. If we go from grid -> grid then (after lerp time) snap to nearest cardinal (probably imperceptible) // 3. If we go from map -> grid then (after lerp time) snap to nearest cardinal if (mover.RelativeEntity.Equals(relative)) return false; // Okay need to get our old relative rotation with respect to our new relative rotation // e.g. if we were right side up on our current grid need to get what that is on our new grid. var currentRotation = Angle.Zero; var targetRotation = Angle.Zero; // Get our current relative rotation if (xformQuery.TryGetComponent(mover.RelativeEntity, out var oldRelativeXform)) { currentRotation = _transform.GetWorldRotation(oldRelativeXform, xformQuery) + mover.RelativeRotation; } if (xformQuery.TryGetComponent(relative, out var relativeXform)) { // This is our current rotation relative to our new parent. mover.RelativeRotation = (currentRotation - _transform.GetWorldRotation(relativeXform, xformQuery)).FlipPositive(); } // If we went from grid -> map we'll preserve our worldrotation if (relative != null && _mapManager.IsMap(relative.Value)) { targetRotation = currentRotation.FlipPositive().Reduced(); } // If we went from grid -> grid OR grid -> map then snap the target to cardinal and lerp there. // OR just rotate to zero (depending on cvar) else if (relative != null && _mapManager.IsGrid(relative.Value)) { if (CameraRotationLocked) targetRotation = Angle.Zero; else targetRotation = mover.RelativeRotation.GetCardinalDir().ToAngle().Reduced(); } mover.RelativeEntity = relative; mover.TargetRelativeRotation = targetRotation; return true; } public Angle GetParentGridAngle(InputMoverComponent mover, EntityQuery xformQuery) { var rotation = mover.RelativeRotation; if (xformQuery.TryGetComponent(mover.RelativeEntity, out var relativeXform)) return (_transform.GetWorldRotation(relativeXform, xformQuery) + rotation); return rotation; } public Angle GetParentGridAngle(InputMoverComponent mover) { return GetParentGridAngle(mover, GetEntityQuery()); } private void OnFollowedParentChange(EntityUid uid, FollowedComponent component, ref EntParentChangedMessage args) { var moverQuery = GetEntityQuery(); var xformQuery = GetEntityQuery(); foreach (var foll in component.Following) { if (!moverQuery.TryGetComponent(foll, out var mover)) continue; var ev = new EntParentChangedMessage(foll, null, args.OldMapId, xformQuery.GetComponent(foll)); OnInputParentChange(foll, mover, ref ev); } } private void OnInputParentChange(EntityUid uid, InputMoverComponent component, ref EntParentChangedMessage args) { // If we change our grid / map then delay updating our LastGridAngle. var relative = args.Transform.GridUid; relative ??= args.Transform.MapUid; if (component.LifeStage < ComponentLifeStage.Running) { component.RelativeEntity = relative; Dirty(component); return; } var oldMapId = args.OldMapId; var mapId = args.Transform.MapID; // If we change maps then reset eye rotation entirely. if (oldMapId != mapId) { component.RelativeEntity = relative; component.TargetRelativeRotation = Angle.Zero; component.RelativeRotation = Angle.Zero; component.LerpTarget = TimeSpan.Zero; Dirty(component); return; } // If we go on a grid and back off then just reset the accumulator. if (relative == component.RelativeEntity) { if (component.LerpTarget >= Timing.CurTime) { component.LerpTarget = TimeSpan.Zero; Dirty(component); } return; } component.LerpTarget = TimeSpan.FromSeconds(InputMoverComponent.LerpTime) + Timing.CurTime; Dirty(component); } private void HandleDirChange(EntityUid entity, Direction dir, ushort subTick, bool state) { // Relayed movement just uses the same keybinds given we're moving the relayed entity // the same as us. if (TryComp(entity, out var relayMover)) { DebugTools.Assert(relayMover.RelayEntity != entity); DebugTools.AssertNotNull(relayMover.RelayEntity); if (TryComp(entity, out var mover)) SetMoveInput(mover, MoveButtons.None); DebugTools.Assert(TryComp(relayMover.RelayEntity, out MovementRelayTargetComponent? targetComp) && targetComp.Entities.Count == 1, "Multiple relayed movers are not supported at the moment"); if (relayMover.RelayEntity != null && !_mobState.IsIncapacitated(entity)) HandleDirChange(relayMover.RelayEntity.Value, dir, subTick, state); return; } if (!TryComp(entity, out var moverComp)) return; // Relay the fact we had any movement event. // TODO: Ideally we'd do these in a tick instead of out of sim. var moveEvent = new MoveInputEvent(entity); RaiseLocalEvent(entity, ref moveEvent); // For stuff like "Moving out of locker" or the likes // We'll relay a movement input to the parent. if (_container.IsEntityInContainer(entity) && TryComp(entity, out var xform) && xform.ParentUid.IsValid() && _mobState.IsAlive(entity)) { var relayMoveEvent = new ContainerRelayMovementEntityEvent(entity); RaiseLocalEvent(xform.ParentUid, ref relayMoveEvent); } SetVelocityDirection(moverComp, dir, subTick, state); } private void OnInputInit(EntityUid uid, InputMoverComponent component, ComponentInit args) { var xform = Transform(uid); if (!xform.ParentUid.IsValid()) return; component.RelativeEntity = xform.GridUid ?? xform.MapUid; component.TargetRelativeRotation = Angle.Zero; } private void HandleRunChange(EntityUid uid, ushort subTick, bool walking) { TryComp(uid, out var moverComp); if (TryComp(uid, out var relayMover)) { // if we swap to relay then stop our existing input if we ever change back. if (moverComp != null) { SetMoveInput(moverComp, MoveButtons.None); } if (relayMover.RelayEntity == null) return; HandleRunChange(relayMover.RelayEntity.Value, subTick, walking); return; } if (moverComp == null) return; SetSprinting(moverComp, subTick, walking); } public (Vector2 Walking, Vector2 Sprinting) GetVelocityInput(InputMoverComponent mover) { if (!Timing.InSimulation) { // Outside of simulation we'll be running client predicted movement per-frame. // So return a full-length vector as if it's a full tick. // Physics system will have the correct time step anyways. var immediateDir = DirVecForButtons(mover.HeldMoveButtons); return mover.Sprinting ? (Vector2.Zero, immediateDir) : (immediateDir, Vector2.Zero); } Vector2 walk; Vector2 sprint; float remainingFraction; if (Timing.CurTick > mover.LastInputTick) { walk = Vector2.Zero; sprint = Vector2.Zero; remainingFraction = 1; } else { walk = mover.CurTickWalkMovement; sprint = mover.CurTickSprintMovement; remainingFraction = (ushort.MaxValue - mover.LastInputSubTick) / (float) ushort.MaxValue; } var curDir = DirVecForButtons(mover.HeldMoveButtons) * remainingFraction; if (mover.Sprinting) { sprint += curDir; } else { walk += curDir; } // Logger.Info($"{curDir}{walk}{sprint}"); return (walk, sprint); } /// /// Toggles one of the four cardinal directions. Each of the four directions are /// composed into a single direction vector, . Enabling /// opposite directions will cancel each other out, resulting in no direction. /// public void SetVelocityDirection(InputMoverComponent component, Direction direction, ushort subTick, bool enabled) { // Logger.Info($"[{_gameTiming.CurTick}/{subTick}] {direction}: {enabled}"); var bit = direction switch { Direction.East => MoveButtons.Right, Direction.North => MoveButtons.Up, Direction.West => MoveButtons.Left, Direction.South => MoveButtons.Down, _ => throw new ArgumentException(nameof(direction)) }; SetMoveInput(component, subTick, enabled, bit); } private void SetMoveInput(InputMoverComponent component, ushort subTick, bool enabled, MoveButtons bit) { // Modifies held state of a movement button at a certain sub tick and updates current tick movement vectors. ResetSubtick(component); if (subTick >= component.LastInputSubTick) { var fraction = (subTick - component.LastInputSubTick) / (float) ushort.MaxValue; ref var lastMoveAmount = ref component.Sprinting ? ref component.CurTickSprintMovement : ref component.CurTickWalkMovement; lastMoveAmount += DirVecForButtons(component.HeldMoveButtons) * fraction; component.LastInputSubTick = subTick; } var buttons = component.HeldMoveButtons; if (enabled) { buttons |= bit; } else { buttons &= ~bit; } SetMoveInput(component, buttons); } private void ResetSubtick(InputMoverComponent component) { if (Timing.CurTick <= component.LastInputTick) return; component.CurTickWalkMovement = Vector2.Zero; component.CurTickSprintMovement = Vector2.Zero; component.LastInputTick = Timing.CurTick; component.LastInputSubTick = 0; } public void SetSprinting(InputMoverComponent component, ushort subTick, bool walking) { // Logger.Info($"[{_gameTiming.CurTick}/{subTick}] Sprint: {enabled}"); SetMoveInput(component, subTick, walking, MoveButtons.Walk); } /// /// Retrieves the normalized direction vector for a specified combination of movement keys. /// private Vector2 DirVecForButtons(MoveButtons buttons) { // key directions are in screen coordinates // _moveDir is in world coordinates // if the camera is moved, this needs to be changed var x = 0; x -= HasFlag(buttons, MoveButtons.Left) ? 1 : 0; x += HasFlag(buttons, MoveButtons.Right) ? 1 : 0; var y = 0; if (DiagonalMovementEnabled || x == 0) { y -= HasFlag(buttons, MoveButtons.Down) ? 1 : 0; y += HasFlag(buttons, MoveButtons.Up) ? 1 : 0; } var vec = new Vector2(x, y); // can't normalize zero length vector if (vec.LengthSquared > 1.0e-6) { // Normalize so that diagonals aren't faster or something. vec = vec.Normalized; } return vec; } private static bool HasFlag(MoveButtons buttons, MoveButtons flag) { return (buttons & flag) == flag; } private sealed class CameraRotateInputCmdHandler : InputCmdHandler { private readonly SharedMoverController _controller; private readonly Angle _angle; public CameraRotateInputCmdHandler(SharedMoverController controller, Direction direction) { _controller = controller; _angle = direction.ToAngle(); } public override bool HandleCmdMessage(ICommonSession? session, InputCmdMessage message) { if (message is not FullInputCmdMessage full || session?.AttachedEntity == null) return false; if (full.State != BoundKeyState.Up) return false; _controller.RotateCamera(session.AttachedEntity.Value, _angle); return false; } } private sealed class CameraResetInputCmdHandler : InputCmdHandler { private readonly SharedMoverController _controller; public CameraResetInputCmdHandler(SharedMoverController controller) { _controller = controller; } public override bool HandleCmdMessage(ICommonSession? session, InputCmdMessage message) { if (message is not FullInputCmdMessage full || session?.AttachedEntity == null) return false; if (full.State != BoundKeyState.Up) return false; _controller.ResetCamera(session.AttachedEntity.Value); return false; } } private sealed class MoverDirInputCmdHandler : InputCmdHandler { private readonly SharedMoverController _controller; private readonly Direction _dir; public MoverDirInputCmdHandler(SharedMoverController controller, Direction dir) { _controller = controller; _dir = dir; } public override bool HandleCmdMessage(ICommonSession? session, InputCmdMessage message) { if (message is not FullInputCmdMessage full || session?.AttachedEntity == null) return false; _controller.HandleDirChange(session.AttachedEntity.Value, _dir, message.SubTick, full.State == BoundKeyState.Down); return false; } } private sealed class WalkInputCmdHandler : InputCmdHandler { private SharedMoverController _controller; public WalkInputCmdHandler(SharedMoverController controller) { _controller = controller; } public override bool HandleCmdMessage(ICommonSession? session, InputCmdMessage message) { if (message is not FullInputCmdMessage full || session?.AttachedEntity == null) return false; _controller.HandleRunChange(session.AttachedEntity.Value, full.SubTick, full.State == BoundKeyState.Down); return false; } } [Serializable, NetSerializable] private sealed class InputMoverComponentState : ComponentState { public MoveButtons Buttons { get; } public readonly bool CanMove; /// /// Our current rotation for movement purposes. This is lerping towards /// public Angle RelativeRotation; /// /// Target rotation relative to the . Typically 0 /// public Angle TargetRelativeRotation; public EntityUid? RelativeEntity; public TimeSpan LerpAccumulator; public InputMoverComponentState(MoveButtons buttons, bool canMove, Angle relativeRotation, Angle targetRelativeRotation, EntityUid? relativeEntity, TimeSpan lerpTarget) { Buttons = buttons; CanMove = canMove; RelativeRotation = relativeRotation; TargetRelativeRotation = targetRelativeRotation; RelativeEntity = relativeEntity; LerpAccumulator = lerpTarget; } } private sealed class ShuttleInputCmdHandler : InputCmdHandler { private readonly SharedMoverController _controller; private readonly ShuttleButtons _button; public ShuttleInputCmdHandler(SharedMoverController controller, ShuttleButtons button) { _controller = controller; _button = button; } public override bool HandleCmdMessage(ICommonSession? session, InputCmdMessage message) { if (message is not FullInputCmdMessage full || session?.AttachedEntity == null) return false; _controller.HandleShuttleInput(session.AttachedEntity.Value, _button, full.SubTick, full.State == BoundKeyState.Down); return false; } } } [Flags] public enum MoveButtons : byte { None = 0, Up = 1, Down = 2, Left = 4, Right = 8, Walk = 16, } [Flags] public enum ShuttleButtons : byte { None = 0, StrafeUp = 1 << 0, StrafeDown = 1 << 1, StrafeLeft = 1 << 2, StrafeRight = 1 << 3, RotateLeft = 1 << 4, RotateRight = 1 << 5, Brake = 1 << 6, } }