using Content.Shared.Movement.Components; using Robust.Shared.Input; using Robust.Shared.Input.Binding; using Robust.Shared.Map; using Robust.Shared.Players; namespace Content.Client.Replay.Spectator; // Partial class handles movement logic for observers. public sealed partial class ReplaySpectatorSystem { public DirectionFlag Direction; /// /// Fallback speed if the observer ghost has no . /// public const float DefaultSpeed = 12; private void InitializeMovement() { var moveUpCmdHandler = new MoverHandler(this, DirectionFlag.North); var moveLeftCmdHandler = new MoverHandler(this, DirectionFlag.West); var moveRightCmdHandler = new MoverHandler(this, DirectionFlag.East); var moveDownCmdHandler = new MoverHandler(this, DirectionFlag.South); CommandBinds.Builder .Bind(EngineKeyFunctions.MoveUp, moveUpCmdHandler) .Bind(EngineKeyFunctions.MoveLeft, moveLeftCmdHandler) .Bind(EngineKeyFunctions.MoveRight, moveRightCmdHandler) .Bind(EngineKeyFunctions.MoveDown, moveDownCmdHandler) .Register(); } private void ShutdownMovement() { CommandBinds.Unregister(); } // Normal mover code works via physics. Replays don't do prediction/physics. You can fudge it by relying on the // fact that only local-player physics is currently predicted, but instead I've just added crude mover logic here. // This just runs on frame updates, no acceleration or friction here. public override void FrameUpdate(float frameTime) { if (_replayPlayback.Replay == null) return; if (_player.LocalPlayer?.ControlledEntity is not { } player) return; if (Direction == DirectionFlag.None) { if (TryComp(player, out InputMoverComponent? cmp)) _mover.LerpRotation(player, cmp, frameTime); return; } if (!IsClientSide(player) || !HasComp(player)) { // Player is trying to move -> behave like the ghost-on-move component. SpawnSpectatorGhost(new EntityCoordinates(player, default), true); return; } if (!TryComp(player, out InputMoverComponent? mover)) return; _mover.LerpRotation(player, mover, frameTime); var effectiveDir = Direction; if ((Direction & DirectionFlag.North) != 0) effectiveDir &= ~DirectionFlag.South; if ((Direction & DirectionFlag.East) != 0) effectiveDir &= ~DirectionFlag.West; var query = GetEntityQuery(); var xform = query.GetComponent(player); var pos = _transform.GetWorldPosition(xform); if (!xform.ParentUid.IsValid()) { // Were they sitting on a grid as it was getting deleted? SetSpectatorPosition(default); return; } // A poor mans grid-traversal system. Should also interrupt ghost-following. // This is very hacky and has already caused bugs. // This is done the way it is because grid traversal gets processed in physics' SimulateWorld() update. // TODO do this properly somehow. _transform.SetGridId(player, xform, null); _transform.AttachToGridOrMap(player); if (xform.ParentUid.IsValid()) _transform.SetGridId(player, xform, Transform(xform.ParentUid).GridUid); var parentRotation = _mover.GetParentGridAngle(mover); var localVec = effectiveDir.AsDir().ToAngle().ToWorldVec(); var worldVec = parentRotation.RotateVec(localVec); var speed = CompOrNull(player)?.BaseSprintSpeed ?? DefaultSpeed; var delta = worldVec * frameTime * speed; _transform.SetWorldPositionRotation(xform, pos + delta, delta.ToWorldAngle()); } private sealed class MoverHandler : InputCmdHandler { private readonly ReplaySpectatorSystem _sys; private readonly DirectionFlag _dir; public MoverHandler(ReplaySpectatorSystem sys, DirectionFlag dir) { _sys = sys; _dir = dir; } public override bool HandleCmdMessage(IEntityManager entManager, ICommonSession? session, IFullInputCmdMessage message) { if (message.State == BoundKeyState.Down) _sys.Direction |= _dir; else _sys.Direction &= ~_dir; return true; } } }