diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index e17b3a6a2f..49383d9779 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -14,6 +14,7 @@ using Content.Client.Parallax.Managers; using Content.Client.Players.PlayTimeTracking; using Content.Client.Preferences; using Content.Client.Radiation.Overlays; +using Content.Client.Replay; using Content.Client.Screenshot; using Content.Client.Singularity; using Content.Client.Stylesheets; @@ -62,6 +63,7 @@ namespace Content.Client.Entry [Dependency] private readonly ExtendedDisconnectInformationManager _extendedDisconnectInformation = default!; [Dependency] private readonly JobRequirementsManager _jobRequirements = default!; [Dependency] private readonly ContentLocalizationManager _contentLoc = default!; + [Dependency] private readonly ContentReplayPlaybackManager _playbackMan = default!; public override void Init() { @@ -131,6 +133,7 @@ namespace Content.Client.Entry _ghostKick.Initialize(); _extendedDisconnectInformation.Initialize(); _jobRequirements.Initialize(); + _playbackMan.Initialize(); //AUTOSCALING default Setup! _configManager.SetCVar("interface.resolutionAutoScaleUpperCutoffX", 1080); @@ -154,7 +157,6 @@ namespace Content.Client.Entry _overlayManager.AddOverlay(new SingularityOverlay()); _overlayManager.AddOverlay(new FlashOverlay()); _overlayManager.AddOverlay(new RadiationPulseOverlay()); - _chatManager.Initialize(); _clientPreferencesManager.Initialize(); _euiManager.Initialize(); diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index c7d45ec085..b98b907cd0 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -18,6 +18,7 @@ using Content.Shared.Administration; using Content.Shared.Administration.Logs; using Content.Shared.Module; using Content.Client.Guidebook; +using Content.Client.Replay; using Content.Shared.Administration.Managers; namespace Content.Client.IoC @@ -44,6 +45,7 @@ namespace Content.Client.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Client/Replay/ContentReplayPlaybackManager.cs b/Content.Client/Replay/ContentReplayPlaybackManager.cs new file mode 100644 index 0000000000..0ecb98ec0d --- /dev/null +++ b/Content.Client/Replay/ContentReplayPlaybackManager.cs @@ -0,0 +1,143 @@ +using Content.Client.Administration.Managers; +using Content.Client.Launcher; +using Content.Client.MainMenu; +using Content.Client.Replay.UI.Loading; +using Content.Client.UserInterface.Systems.Chat; +using Content.Shared.Chat; +using Content.Shared.GameTicking; +using Content.Shared.Hands; +using Content.Shared.Instruments; +using Content.Shared.Popups; +using Content.Shared.Projectiles; +using Content.Shared.Weapons.Melee; +using Content.Shared.Weapons.Melee.Events; +using Content.Shared.Weapons.Ranged.Events; +using Content.Shared.Weapons.Ranged.Systems; +using Robust.Client; +using Robust.Client.Console; +using Robust.Client.GameObjects; +using Robust.Client.Replays.Loading; +using Robust.Client.Replays.Playback; +using Robust.Client.State; +using Robust.Client.Timing; +using Robust.Client.UserInterface; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; + +namespace Content.Client.Replay; + +public sealed class ContentReplayPlaybackManager +{ + [Dependency] private readonly IStateManager _stateMan = default!; + [Dependency] private readonly IClientGameTiming _timing = default!; + [Dependency] private readonly IReplayLoadManager _loadMan = default!; + [Dependency] private readonly IGameController _controller = default!; + [Dependency] private readonly IClientEntityManager _entMan = default!; + [Dependency] private readonly IUserInterfaceManager _uiMan = default!; + [Dependency] private readonly IReplayPlaybackManager _playback = default!; + [Dependency] private readonly IClientConGroupController _conGrp = default!; + [Dependency] private readonly IClientAdminManager _adminMan = default!; + + /// + /// UI state to return to when stopping a replay or loading fails. + /// + public Type? DefaultState; + + private bool _initialized; + + public void Initialize() + { + if (_initialized) + return; + + _initialized = true; + _playback.HandleReplayMessage += OnHandleReplayMessage; + _playback.ReplayPlaybackStopped += OnReplayPlaybackStopped; + _playback.ReplayPlaybackStarted += OnReplayPlaybackStarted; + _playback.ReplayCheckpointReset += OnCheckpointReset; + _loadMan.LoadOverride += LoadOverride; + } + + private void LoadOverride(IWritableDirProvider dir, ResPath resPath) + { + var screen = _stateMan.RequestStateChange>(); + screen.Job = new ContentLoadReplayJob(1/60f, dir, resPath, _loadMan, screen); + screen.OnJobFinished += (_, e) => OnFinishedLoading(e); + } + + private void OnFinishedLoading(Exception? exception) + { + if (exception != null) + { + ReturnToDefaultState(); + _uiMan.Popup(Loc.GetString("replay-loading-failed", ("reason", exception))); + } + } + + public void ReturnToDefaultState() + { + if (DefaultState != null) + _stateMan.RequestStateChange(DefaultState); + else if (_controller.LaunchState.FromLauncher) + _stateMan.RequestStateChange().SetDisconnected(); + else + _stateMan.RequestStateChange(); + } + + private void OnCheckpointReset() + { + // This function removes future chat messages when rewinding time. + + // TODO REPLAYS add chat messages when jumping forward in time. + // Need to allow content to add data to checkpoint states. + + _uiMan.GetUIController().History.RemoveAll(x => x.Item1 > _timing.CurTick); + _uiMan.GetUIController().Repopulate(); + } + + private bool OnHandleReplayMessage(object message, bool skipEffects) + { + switch (message) + { + case ChatMessage chat: + // Just pass on the chat message to the UI controller, but skip speech-bubbles if we are fast-forwarding. + _uiMan.GetUIController().ProcessChatMessage(chat, speechBubble: !skipEffects); + return true; + // TODO REPLAYS figure out a cleaner way of doing this. This sucks. + // Next: we want to avoid spamming animations, sounds, and pop-ups while scrubbing or rewinding time + // (e.g., to rewind 1 tick, we really rewind ~60 and then fast forward 59). Currently, this is + // effectively an EntityEvent blacklist. But this is kinda shit and should be done differently somehow. + // The unifying aspect of these events is that they trigger pop-ups, UI changes, spawn client-side + // entities or start animations. + case RoundEndMessageEvent: + case PopupEvent: + case AudioMessage: + case PickupAnimationEvent: + case MeleeLungeEvent: + case SharedGunSystem.HitscanEvent: + case ImpactEffectEvent: + case MuzzleFlashEvent: + case DamageEffectEvent: + case InstrumentStartMidiEvent: + case InstrumentMidiEventEvent: + case InstrumentStopMidiEvent: + if (!skipEffects) + _entMan.DispatchReceivedNetworkMsg((EntityEventArgs)message); + return true; + } + + return false; + } + + + private void OnReplayPlaybackStarted() + { + _conGrp.Implementation = new ReplayConGroup(); + } + + private void OnReplayPlaybackStopped() + { + _conGrp.Implementation = (IClientConGroupImplementation)_adminMan; + ReturnToDefaultState(); + } +} diff --git a/Content.Client/Replay/LoadReplayJob.cs b/Content.Client/Replay/LoadReplayJob.cs new file mode 100644 index 0000000000..1064008609 --- /dev/null +++ b/Content.Client/Replay/LoadReplayJob.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Content.Client.Replay.UI.Loading; +using Robust.Client.Replays.Loading; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; + +namespace Content.Client.Replay; + +public sealed class ContentLoadReplayJob : LoadReplayJob +{ + private readonly LoadingScreen _screen; + + public ContentLoadReplayJob( + float maxTime, + IWritableDirProvider dir, + ResPath path, + IReplayLoadManager loadMan, + LoadingScreen screen) + : base(maxTime, dir, path, loadMan) + { + _screen = screen; + } + + protected override async Task Yield(float value, float maxValue, LoadingState state, bool force) + { + var header = Loc.GetString("replay-loading", ("cur", (int)state + 1), ("total", 5)); + var subText = Loc.GetString(state switch + { + LoadingState.ReadingFiles => "replay-loading-reading", + LoadingState.ProcessingFiles => "replay-loading-processing", + LoadingState.Spawning => "replay-loading-spawning", + LoadingState.Initializing => "replay-loading-initializing", + _ => "replay-loading-starting", + }); + _screen.UpdateProgress(value, maxValue, header, subText); + + await base.Yield(value, maxValue, state, force); + } +} diff --git a/Content.Client/Replay/ReplayConGroup.cs b/Content.Client/Replay/ReplayConGroup.cs new file mode 100644 index 0000000000..8e85632683 --- /dev/null +++ b/Content.Client/Replay/ReplayConGroup.cs @@ -0,0 +1,13 @@ +using Robust.Client.Console; + +namespace Content.Client.Replay; + +public sealed class ReplayConGroup : IClientConGroupImplementation +{ + public event Action? ConGroupUpdated; + public bool CanAdminMenu() => true; + public bool CanAdminPlace() => true; + public bool CanCommand(string cmdName) => true; + public bool CanScript() => true; + public bool CanViewVar() => true; +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorComponent.cs b/Content.Client/Replay/Spectator/ReplaySpectatorComponent.cs new file mode 100644 index 0000000000..509240a386 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorComponent.cs @@ -0,0 +1,9 @@ +namespace Content.Client.Replay.Spectator; + +/// +/// This component indicates that this entity currently has a replay spectator/observer attached to it. +/// +[RegisterComponent] +public sealed class ReplaySpectatorComponent : Component +{ +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Blockers.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Blockers.cs new file mode 100644 index 0000000000..86d113defb --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Blockers.cs @@ -0,0 +1,44 @@ +using Content.Shared.Hands; +using Content.Shared.Interaction.Events; +using Content.Shared.Inventory.Events; +using Content.Shared.Item; +using Content.Shared.Movement.Events; +using Content.Shared.Physics.Pull; +using Content.Shared.Throwing; + +namespace Content.Client.Replay.Spectator; + +public sealed partial class ReplaySpectatorSystem +{ + private void InitializeBlockers() + { + // Block most interactions to avoid mispredicts + // This **shouldn't** be required, but just in case. + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnUpdateCanMove); + SubscribeLocalEvent(OnUpdateCanMove); + SubscribeLocalEvent(OnPullAttempt); + } + + private void OnAttempt(EntityUid uid, ReplaySpectatorComponent component, CancellableEntityEventArgs args) + { + args.Cancel(); + } + + private void OnUpdateCanMove(EntityUid uid, ReplaySpectatorComponent component, CancellableEntityEventArgs args) + { + args.Cancel(); + } + + private void OnPullAttempt(EntityUid uid, ReplaySpectatorComponent component, PullAttemptEvent args) + { + args.Cancelled = true; + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs new file mode 100644 index 0000000000..46c268d310 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs @@ -0,0 +1,123 @@ +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(cmp, frameTime); + return; + } + + if (!player.IsClientSide() || !HasComp(player)) + { + // Player is trying to move -> behave like the ghost-on-move component. + SpawnObserverGhost(new EntityCoordinates(player, default), true); + return; + } + + if (!TryComp(player, out InputMoverComponent? mover)) + return; + + _mover.LerpRotation(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, query); + + if (!xform.ParentUid.IsValid()) + { + // Were they sitting on a grid as it was getting deleted? + SetObserverPosition(default); + return; + } + + // A poor mans grid-traversal system. Should also interrupt ghost-following. + _transform.SetGridId(player, xform, null); + _transform.AttachToGridOrMap(player); + + var parentRotation = _mover.GetParentGridAngle(mover, query); + 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(), query); + } + + 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(ICommonSession? session, InputCmdMessage message) + { + if (message is not FullInputCmdMessage full) + return false; + + if (full.State == BoundKeyState.Down) + _sys.Direction |= _dir; + else + _sys.Direction &= ~_dir; + + return true; + } + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs new file mode 100644 index 0000000000..40f0fd1880 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs @@ -0,0 +1,281 @@ +using System.Linq; +using Content.Client.Replay.UI; +using Content.Shared.Movement.Components; +using Content.Shared.Movement.Systems; +using Content.Shared.Verbs; +using Robust.Client; +using Robust.Client.GameObjects; +using Robust.Client.Player; +using Robust.Client.Replays.Playback; +using Robust.Client.State; +using Robust.Shared.Console; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Utility; + +namespace Content.Client.Replay.Spectator; + +/// +/// This system handles spawning replay observer ghosts and maintaining their positions when traveling through time. +/// It also blocks most normal interactions, just in case. +/// +/// +/// E.g., if an observer is on a grid, and then jumps forward or backward in time to a point where the grid does not +/// exist, where should the observer go? This attempts to maintain their position and eye rotation or just re-spawns +/// them as needed. +/// +public sealed partial class ReplaySpectatorSystem : EntitySystem +{ + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IConsoleHost _conHost = default!; + [Dependency] private readonly IStateManager _stateMan = default!; + [Dependency] private readonly TransformSystem _transform = default!; + [Dependency] private readonly SharedMoverController _mover = default!; + [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly SharedContentEyeSystem _eye = default!; + [Dependency] private readonly IReplayPlaybackManager _replayPlayback = default!; + + private ObserverData? _oldPosition; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent>(OnGetAlternativeVerbs); + SubscribeLocalEvent(OnTerminating); + SubscribeLocalEvent(OnDetached); + + InitializeBlockers(); + _conHost.RegisterCommand("observe", ObserveCommand); + + _replayPlayback.BeforeSetTick += OnBeforeSetTick; + _replayPlayback.AfterSetTick += OnAfterSetTick; + _replayPlayback.ReplayPlaybackStarted += OnPlaybackStarted; + _replayPlayback.ReplayPlaybackStopped += OnPlaybackStopped; + } + + private void OnPlaybackStarted() + { + InitializeMovement(); + SetObserverPosition(default); + } + + private void OnAfterSetTick() + { + if (_oldPosition != null) + SetObserverPosition(_oldPosition.Value); + _oldPosition = null; + } + + public override void Shutdown() + { + base.Shutdown(); + _conHost.UnregisterCommand("observe"); + _replayPlayback.BeforeSetTick -= OnBeforeSetTick; + _replayPlayback.AfterSetTick -= OnAfterSetTick; + _replayPlayback.ReplayPlaybackStarted -= OnPlaybackStarted; + _replayPlayback.ReplayPlaybackStopped -= OnPlaybackStopped; + } + + private void OnPlaybackStopped() + { + ShutdownMovement(); + } + + private void OnBeforeSetTick() + { + _oldPosition = GetObserverPosition(); + } + + private void OnDetached(EntityUid uid, ReplaySpectatorComponent component, PlayerDetachedEvent args) + { + if (uid.IsClientSide()) + QueueDel(uid); + else + RemCompDeferred(uid, component); + } + + public void SetObserverPosition(ObserverData observer) + { + if (Exists(observer.Entity) && Transform(observer.Entity).MapID != MapId.Nullspace) + { + _player.LocalPlayer!.AttachEntity(observer.Entity, EntityManager, _client); + return; + } + + if (observer.Local != null && observer.Local.Value.Coords.IsValid(EntityManager)) + { + var newXform = SpawnObserverGhost(observer.Local.Value.Coords, false); + newXform.LocalRotation = observer.Local.Value.Rot; + } + else if (observer.World != null && observer.World.Value.Coords.IsValid(EntityManager)) + { + var newXform = SpawnObserverGhost(observer.World.Value.Coords, true); + newXform.LocalRotation = observer.World.Value.Rot; + } + else if (TryFindFallbackSpawn(out var coords)) + { + var newXform = SpawnObserverGhost(coords, true); + newXform.LocalRotation = 0; + } + else + { + Logger.Error("Failed to find a suitable observer spawn point"); + return; + } + + if (observer.Eye != null && TryComp(_player.LocalPlayer?.ControlledEntity, out InputMoverComponent? newMover)) + { + newMover.RelativeEntity = observer.Eye.Value.Ent; + newMover.TargetRelativeRotation = newMover.RelativeRotation = observer.Eye.Value.Rot; + } + } + + private bool TryFindFallbackSpawn(out EntityCoordinates coords) + { + var uid = EntityQuery().OrderByDescending(x => x.LocalAABB.Size.LengthSquared).FirstOrDefault()?.Owner; + coords = new EntityCoordinates(uid ?? default, default); + return uid != null; + } + + public struct ObserverData + { + // TODO REPLAYS handle ghost-following. + public EntityUid Entity; + public (EntityCoordinates Coords, Angle Rot)? Local; + public (EntityCoordinates Coords, Angle Rot)? World; + public (EntityUid? Ent, Angle Rot)? Eye; + } + + public ObserverData GetObserverPosition() + { + var obs = new ObserverData(); + if (_player.LocalPlayer?.ControlledEntity is { } player && TryComp(player, out TransformComponent? xform) && xform.MapUid != null) + { + obs.Local = (xform.Coordinates, xform.LocalRotation); + obs.World = (new(xform.MapUid.Value, xform.WorldPosition), xform.WorldRotation); + + if (TryComp(player, out InputMoverComponent? mover)) + obs.Eye = (mover.RelativeEntity, mover.TargetRelativeRotation); + + obs.Entity = player; + } + + return obs; + } + + private void OnTerminating(EntityUid uid, ReplaySpectatorComponent component, ref EntityTerminatingEvent args) + { + if (uid != _player.LocalPlayer?.ControlledEntity) + return; + + var xform = Transform(uid); + if (xform.MapUid == null || Terminating(xform.MapUid.Value)) + return; + + SpawnObserverGhost(new EntityCoordinates(xform.MapUid.Value, default), true); + } + + private void OnGetAlternativeVerbs(GetVerbsEvent ev) + { + if (_replayPlayback.Replay == null) + return; + + var verb = new AlternativeVerb + { + Priority = 100, + Act = () => + { + SpectateEntity(ev.Target); + }, + + Text = "Observe", + Icon = new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/VerbIcons/vv.svg.192dpi.png")) + }; + + ev.Verbs.Add(verb); + } + + public void SpectateEntity(EntityUid target) + { + if (_player.LocalPlayer == null) + return; + + var old = _player.LocalPlayer.ControlledEntity; + + if (old == target) + { + // un-visit + SpawnObserverGhost(Transform(target).Coordinates, true); + return; + } + + _player.LocalPlayer.AttachEntity(target, EntityManager, _client); + EnsureComp(target); + + if (old == null) + return; + + if (old.Value.IsClientSide()) + Del(old.Value); + else + RemComp(old.Value); + + _stateMan.RequestStateChange(); + } + + public TransformComponent SpawnObserverGhost(EntityCoordinates coords, bool gridAttach) + { + if (_player.LocalPlayer == null) + throw new InvalidOperationException(); + + var old = _player.LocalPlayer.ControlledEntity; + + var ent = Spawn("MobObserver", coords); + _eye.SetMaxZoom(ent, Vector2.One * 5); + EnsureComp(ent); + + var xform = Transform(ent); + + if (gridAttach) + _transform.AttachToGridOrMap(ent); + + _player.LocalPlayer.AttachEntity(ent, EntityManager, _client); + + if (old != null) + { + if (old.Value.IsClientSide()) + QueueDel(old.Value); + else + RemComp(old.Value); + } + + _stateMan.RequestStateChange(); + + return xform; + } + + private void ObserveCommand(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length == 0) + { + if (_player.LocalPlayer?.ControlledEntity is { } current) + SpawnObserverGhost(new EntityCoordinates(current, default), true); + return; + } + + if (!EntityUid.TryParse(args[0], out var uid)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-uid", ("arg", args[0]))); + return; + } + + if (!TryComp(uid, out TransformComponent? xform)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-entity-exist", ("arg", args[0]))); + return; + } + + SpectateEntity(uid); + } +} diff --git a/Content.Client/Replay/UI/Loading/LoadingScreen.cs b/Content.Client/Replay/UI/Loading/LoadingScreen.cs new file mode 100644 index 0000000000..f3f75a2950 --- /dev/null +++ b/Content.Client/Replay/UI/Loading/LoadingScreen.cs @@ -0,0 +1,51 @@ +using Robust.Client.ResourceManagement; +using Robust.Client.State; +using Robust.Client.UserInterface; +using Robust.Shared.CPUJob.JobQueues; +using Robust.Shared.Timing; + +namespace Content.Client.Replay.UI.Loading; + +[Virtual] +public class LoadingScreen : State +{ + [Dependency] private readonly IResourceCache _resourceCache = default!; + [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; + + public event Action? OnJobFinished; + private LoadingScreenControl _screen = default!; + public Job? Job; + + public override void FrameUpdate(FrameEventArgs e) + { + base.FrameUpdate(e); + if (Job == null) + return; + + Job.Run(); + if (Job.Status != JobStatus.Finished) + return; + + OnJobFinished?.Invoke(Job.Result, Job.Exception); + Job = null; + } + + protected override void Startup() + { + _screen = new(_resourceCache); + _userInterfaceManager.StateRoot.AddChild(_screen); + } + + protected override void Shutdown() + { + _screen.Dispose(); + } + + public void UpdateProgress(float value, float maxValue, string header, string subtext = "") + { + _screen.Bar.Value = value; + _screen.Bar.MaxValue = maxValue; + _screen.Header.Text = header; + _screen.Subtext.Text = subtext; + } +} diff --git a/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml new file mode 100644 index 0000000000..58f353a5ff --- /dev/null +++ b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml.cs b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml.cs new file mode 100644 index 0000000000..bbb5111438 --- /dev/null +++ b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml.cs @@ -0,0 +1,39 @@ +using Content.Client.Resources; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Utility; + +namespace Content.Client.Replay.UI.Loading; + +[GenerateTypedNameReferences] +public sealed partial class LoadingScreenControl : Control +{ + public static SpriteSpecifier Sprite = new SpriteSpecifier.Rsi(new ("/Textures/Mobs/Silicon/Bots/mommi.rsi"), "wiggle"); + + public LoadingScreenControl(IResourceCache resCache) + { + RobustXamlLoader.Load(this); + + LayoutContainer.SetAnchorPreset(this, LayoutContainer.LayoutPreset.Wide); + Header.FontOverride = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 24); + Subtext.FontOverride = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 12); + + SpriteLeft.SetFromSpriteSpecifier(Sprite); + SpriteRight.SetFromSpriteSpecifier(Sprite); + SpriteLeft.HorizontalAlignment = HAlignment.Stretch; + SpriteLeft.VerticalAlignment = VAlignment.Stretch; + SpriteLeft.DisplayRect.Stretch = TextureRect.StretchMode.KeepAspectCentered; + SpriteRight.DisplayRect.Stretch = TextureRect.StretchMode.KeepAspectCentered; + + Background.PanelOverride = new StyleBoxFlat() + { + BackgroundColor = Color.FromHex("#303033"), + BorderColor = Color.FromHex("#5a5a5a"), + BorderThickness = new Thickness(4) + }; + } +} diff --git a/Content.Client/Replay/UI/ReplayGhostState.cs b/Content.Client/Replay/UI/ReplayGhostState.cs new file mode 100644 index 0000000000..284e1198a3 --- /dev/null +++ b/Content.Client/Replay/UI/ReplayGhostState.cs @@ -0,0 +1,40 @@ +using Content.Client.UserInterface.Systems.Actions.Widgets; +using Content.Client.UserInterface.Systems.Alerts.Widgets; +using Content.Client.UserInterface.Systems.Ghost.Widgets; +using Content.Client.UserInterface.Systems.Hotbar.Widgets; + +namespace Content.Client.Replay.UI; + +/// +/// Gameplay state when moving around a replay as a ghost. +/// +public sealed class ReplayGhostState : ReplaySpectateEntityState +{ + protected override void Startup() + { + base.Startup(); + + var screen = UserInterfaceManager.ActiveScreen; + if (screen == null) + return; + + screen.ShowWidget(false); + screen.ShowWidget(false); + screen.ShowWidget(false); + screen.ShowWidget(false); + } + + protected override void Shutdown() + { + var screen = UserInterfaceManager.ActiveScreen; + if (screen != null) + { + screen.ShowWidget(true); + screen.ShowWidget(true); + screen.ShowWidget(true); + screen.ShowWidget(true); + } + + base.Shutdown(); + } +} diff --git a/Content.Client/Replay/UI/ReplaySpectateEntityState.cs b/Content.Client/Replay/UI/ReplaySpectateEntityState.cs new file mode 100644 index 0000000000..f36c366dae --- /dev/null +++ b/Content.Client/Replay/UI/ReplaySpectateEntityState.cs @@ -0,0 +1,48 @@ +using Content.Client.Gameplay; +using Content.Client.UserInterface.Systems.Chat; +using Content.Client.UserInterface.Systems.MenuBar.Widgets; +using Robust.Client.Replays.UI; +using static Robust.Client.UserInterface.Controls.LayoutContainer; + +namespace Content.Client.Replay.UI; + +/// +/// Gameplay state when observing/spectating an entity during a replay. +/// +[Virtual] +public class ReplaySpectateEntityState : GameplayState +{ + protected override void Startup() + { + base.Startup(); + + var screen = UserInterfaceManager.ActiveScreen; + if (screen == null) + return; + + screen.ShowWidget(false); + SetAnchorAndMarginPreset(screen.GetOrAddWidget(), LayoutPreset.TopLeft, margin: 10); + + foreach (var chatbox in UserInterfaceManager.GetUIController().Chats) + { + chatbox.ChatInput.Visible = false; + } + } + + protected override void Shutdown() + { + var screen = UserInterfaceManager.ActiveScreen; + if (screen != null) + { + screen.RemoveWidget(); + screen.ShowWidget(true); + } + + foreach (var chatbox in UserInterfaceManager.GetUIController().Chats) + { + chatbox.ChatInput.Visible = true; + } + + base.Shutdown(); + } +} diff --git a/Content.Replay/Content.Replay.csproj b/Content.Replay/Content.Replay.csproj new file mode 100644 index 0000000000..5d5880f23a --- /dev/null +++ b/Content.Replay/Content.Replay.csproj @@ -0,0 +1,26 @@ + + + $(TargetFramework) + 10 + false + false + ..\bin\Content.Replay\ + Exe + nullable + enable + + + + + + + + + + + + + + + + diff --git a/Content.Replay/EntryPoint.cs b/Content.Replay/EntryPoint.cs new file mode 100644 index 0000000000..ed6460a7e7 --- /dev/null +++ b/Content.Replay/EntryPoint.cs @@ -0,0 +1,34 @@ +using Content.Client.Replay; +using Content.Replay.Menu; +using JetBrains.Annotations; +using Robust.Client; +using Robust.Client.Console; +using Robust.Client.State; +using Robust.Shared.ContentPack; + +namespace Content.Replay; + +[UsedImplicitly] +public sealed class EntryPoint : GameClient +{ + [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly IStateManager _stateMan = default!; + [Dependency] private readonly ContentReplayPlaybackManager _contentReplayPlaybackMan = default!; + [Dependency] private readonly IClientConGroupController _conGrp = default!; + + public override void Init() + { + base.Init(); + IoCManager.BuildGraph(); + IoCManager.InjectDependencies(this); + } + + public override void PostInit() + { + base.PostInit(); + _client.StartSinglePlayer(); + _conGrp.Implementation = new ReplayConGroup(); + _contentReplayPlaybackMan.DefaultState = typeof(ReplayMainScreen); + _stateMan.RequestStateChange(); + } +} diff --git a/Content.Replay/GlobalUsings.cs b/Content.Replay/GlobalUsings.cs new file mode 100644 index 0000000000..f0cbfd5b1a --- /dev/null +++ b/Content.Replay/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Global usings for Content.Replay + +global using System; +global using System.Collections.Generic; +global using Robust.Shared.Log; +global using Robust.Shared.Localization; +global using Robust.Shared.GameObjects; +global using Robust.Shared.IoC; +global using Robust.Shared.Maths; diff --git a/Content.Replay/Menu/ReplayMainMenu.cs b/Content.Replay/Menu/ReplayMainMenu.cs new file mode 100644 index 0000000000..6748c20988 --- /dev/null +++ b/Content.Replay/Menu/ReplayMainMenu.cs @@ -0,0 +1,283 @@ +using System.Linq; +using Content.Client.Message; +using Content.Client.UserInterface.Systems.EscapeMenu; +using Robust.Client; +using Robust.Client.Replays.Loading; +using Robust.Client.ResourceManagement; +using Robust.Client.Serialization; +using Robust.Client.State; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.Serialization.Markdown.Value; +using Robust.Shared.Utility; +using TerraFX.Interop.Windows; +using static Robust.Shared.Replays.IReplayRecordingManager; +using IResourceManager = Robust.Shared.ContentPack.IResourceManager; + +namespace Content.Replay.Menu; + +/// +/// Main menu screen for selecting and loading replays. +/// +public sealed class ReplayMainScreen : State +{ + [Dependency] private readonly IResourceManager _resMan = default!; + [Dependency] private readonly IComponentFactory _factory = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IReplayLoadManager _loadMan = default!; + [Dependency] private readonly IResourceCache _resourceCache = default!; + [Dependency] private readonly IGameController _controllerProxy = default!; + [Dependency] private readonly IClientRobustSerializer _serializer = default!; + [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; + + private ReplayMainMenuControl _mainMenuControl = default!; + private SelectReplayWindow? _selectWindow; + private ResPath _directory; + private List<(string Name, ResPath Path)> _replays = new(); + private ResPath? _selected; + + protected override void Startup() + { + _mainMenuControl = new(_resourceCache); + _userInterfaceManager.StateRoot.AddChild(_mainMenuControl); + + _mainMenuControl.SelectButton.OnPressed += OnSelectPressed; + _mainMenuControl.QuitButton.OnPressed += QuitButtonPressed; + _mainMenuControl.OptionsButton.OnPressed += OptionsButtonPressed; + _mainMenuControl.FolderButton.OnPressed += OnFolderPressed; + _mainMenuControl.LoadButton.OnPressed += OnLoadpressed; + + _directory = new ResPath(_cfg.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); + RefreshReplays(); + SelectReplay(_replays.FirstOrNull()?.Path); + if (_selected == null) // force initial update + UpdateSelectedInfo(); + } + + /// + /// Read replay meta-data and update the replay info box. + /// + private void UpdateSelectedInfo() + { + var info = _mainMenuControl.Info; + + if (_selected is not { } replay) + { + info.SetMarkup(Loc.GetString("replay-info-none-selected")); + info.HorizontalAlignment = Control.HAlignment.Center; + info.VerticalAlignment = Control.VAlignment.Center; + _mainMenuControl.LoadButton.Disabled = true; + return; + } + + if (!_resMan.UserData.Exists(replay) + || _loadMan.LoadYamlMetadata(_resMan.UserData, replay) is not { } data) + { + info.SetMarkup(Loc.GetString("replay-info-invalid")); + info.HorizontalAlignment = Control.HAlignment.Center; + info.VerticalAlignment = Control.VAlignment.Center; + _mainMenuControl.LoadButton.Disabled = true; + return; + } + + var file = replay.ToRelativePath().ToString(); + data.TryGet(Time, out var timeNode); + data.TryGet(Duration, out var durationNode); + data.TryGet("roundId", out var roundIdNode); + data.TryGet(Hash, out var hashNode); + data.TryGet(CompHash, out var compHashNode); + DateTime.TryParse(timeNode?.Value, out var time); + TimeSpan.TryParse(durationNode?.Value, out var duration); + + var forkId = string.Empty; + if (data.TryGet(Fork, out var forkNode)) + { + // TODO REPLAYS somehow distribute and load from build.json? + var clientFork = _cfg.GetCVar(CVars.BuildForkId); + if (string.IsNullOrWhiteSpace(clientFork)) + forkId = forkNode.Value; + else if (forkNode.Value == clientFork) + forkId = $"[color=green]{forkNode.Value}[/color]"; + else + forkId = $"[color=yellow]{forkNode.Value}[/color]"; + } + + var forkVersion = string.Empty; + if (data.TryGet(ForkVersion, out var versionNode)) + { + forkVersion = versionNode.Value; + // Why does this not have a try-convert function? I just want to check if it looks like a hash code. + try + { + Convert.FromHexString(forkVersion); + // version is a probably some git commit. Crop it to keep the info box small. + forkVersion = forkVersion[..16]; + } + catch + { + // ignored + } + + // TODO REPLAYS somehow distribute and load from build.json? + var clientVer = _cfg.GetCVar(CVars.BuildVersion); + if (!string.IsNullOrWhiteSpace(clientVer)) + { + if (versionNode.Value == clientVer) + forkVersion = $"[color=green]{forkVersion}[/color]"; + else + forkVersion = $"[color=yellow]{forkVersion}[/color]"; + } + } + + if (hashNode == null) + throw new Exception("Invalid metadata file. Missing type hash"); + + var typeHash = hashNode.Value; + _mainMenuControl.LoadButton.Disabled = false; + if (Convert.FromHexString(typeHash).SequenceEqual(_serializer.GetSerializableTypesHash())) + { + typeHash = $"[color=green]{typeHash[..16]}[/color]"; + } + else + { + typeHash = $"[color=red]{typeHash[..16]}[/color]"; + _mainMenuControl.LoadButton.Disabled = true; + } + + if (compHashNode == null) + throw new Exception("Invalid metadata file. Missing component hash"); + + var compHash = compHashNode.Value; + if (Convert.FromHexString(compHash).SequenceEqual(_factory.GetHash(true))) + { + compHash = $"[color=green]{compHash[..16]}[/color]"; + _mainMenuControl.LoadButton.Disabled = false; + } + else + { + compHash = $"[color=red]{compHash[..16]}[/color]"; + _mainMenuControl.LoadButton.Disabled = true; + } + + var engineVersion = string.Empty; + if (data.TryGet(Engine, out var engineNode)) + { + var clientVer = _cfg.GetCVar(CVars.BuildEngineVersion); + if (string.IsNullOrWhiteSpace(clientVer)) + engineVersion = engineNode.Value; + else if (engineNode.Value == clientVer) + engineVersion = $"[color=green]{engineNode.Value}[/color]"; + else + engineVersion = $"[color=yellow]{engineNode.Value}[/color]"; + } + + // Strip milliseconds. Apparently there is no general format string that suppresses milliseconds. + duration = new((int)Math.Floor(duration.TotalDays), duration.Hours, duration.Minutes, duration.Seconds); + + data.TryGet(Name, out var nameNode); + var name = nameNode?.Value ?? string.Empty; + + info.HorizontalAlignment = Control.HAlignment.Left; + info.VerticalAlignment = Control.VAlignment.Top; + info.SetMarkup(Loc.GetString( + "replay-info-info", + ("file", file), + ("name", name), + ("time", time), + ("roundId", roundIdNode?.Value ?? "???"), + ("duration", duration), + ("forkId", forkId), + ("version", forkVersion), + ("engVersion", engineVersion), + ("compHash", compHash), + ("hash", typeHash))); + } + + private void OnFolderPressed(BaseButton.ButtonEventArgs obj) + { + _resMan.UserData.CreateDir(_directory); + _resMan.UserData.OpenOsWindow(_directory); + } + + private void OnLoadpressed(BaseButton.ButtonEventArgs obj) + { + if (_selected.HasValue) + _loadMan.LoadAndStartReplay(_resMan.UserData, _selected.Value); + } + + private void RefreshReplays() + { + _replays.Clear(); + + foreach (var entry in _resMan.UserData.DirectoryEntries(_directory)) + { + var file = _directory / entry; + try + { + var data = _loadMan.LoadYamlMetadata(_resMan.UserData, file); + if (data == null) + continue; + + var name = data.Get(Name).Value; + _replays.Add((name, file)); + + } + catch + { + // Ignore file + } + } + + _selectWindow?.Repopulate(_replays); + if (_selected.HasValue && _replays.All(x => x.Path != _selected.Value)) + SelectReplay(null); + else + _selectWindow?.UpdateSelected(_selected); + } + + public void SelectReplay(ResPath? replay) + { + if (_selected == replay) + return; + + _selected = replay; + try + { + UpdateSelectedInfo(); + } + catch (Exception ex) + { + Logger.Error($"Failed to load replay info. Exception: {ex}"); + SelectReplay(null); + return; + } + _selectWindow?.UpdateSelected(replay); + } + + protected override void Shutdown() + { + _mainMenuControl.Dispose(); + _selectWindow?.Dispose(); + } + + private void OptionsButtonPressed(BaseButton.ButtonEventArgs args) + { + _userInterfaceManager.GetUIController().ToggleWindow(); + } + + private void QuitButtonPressed(BaseButton.ButtonEventArgs args) + { + _controllerProxy.Shutdown(); + } + + private void OnSelectPressed(BaseButton.ButtonEventArgs args) + { + RefreshReplays(); + _selectWindow ??= new(this); + _selectWindow.Repopulate(_replays); + _selectWindow.UpdateSelected(_selected); + _selectWindow.OpenCentered(); + } +} diff --git a/Content.Replay/Menu/ReplayMainMenuControl.xaml b/Content.Replay/Menu/ReplayMainMenuControl.xaml new file mode 100644 index 0000000000..f26db1121f --- /dev/null +++ b/Content.Replay/Menu/ReplayMainMenuControl.xaml @@ -0,0 +1,52 @@ + + + + + +