diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs index 46c268d310..52cf3d9c3b 100644 --- a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs @@ -57,7 +57,7 @@ public sealed partial class ReplaySpectatorSystem if (!player.IsClientSide() || !HasComp(player)) { // Player is trying to move -> behave like the ghost-on-move component. - SpawnObserverGhost(new EntityCoordinates(player, default), true); + SpawnSpectatorGhost(new EntityCoordinates(player, default), true); return; } @@ -80,7 +80,7 @@ public sealed partial class ReplaySpectatorSystem if (!xform.ParentUid.IsValid()) { // Were they sitting on a grid as it was getting deleted? - SetObserverPosition(default); + SetSpectatorPosition(default); return; } diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Position.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Position.cs new file mode 100644 index 0000000000..3b17191c16 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Position.cs @@ -0,0 +1,132 @@ +using System.Linq; +using Content.Shared.Movement.Components; +using Robust.Client.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; + +namespace Content.Client.Replay.Spectator; + +// This partial class contains functions for getting and setting the spectator's position data, so that +// a consistent view/camera can be maintained when jumping around in time. +public sealed partial class ReplaySpectatorSystem +{ + /// + /// Simple struct containing position & rotation data for maintaining a persistent view when jumping around in time. + /// + public struct SpectatorPosition + { + // 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 SpectatorPosition GetSpectatorPosition() + { + var obs = new SpectatorPosition(); + 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 OnBeforeSetTick() + { + _oldPosition = GetSpectatorPosition(); + } + + private void OnAfterSetTick() + { + if (_oldPosition != null) + SetSpectatorPosition(_oldPosition.Value); + _oldPosition = null; + } + + public void SetSpectatorPosition(SpectatorPosition spectatorPosition) + { + if (Exists(spectatorPosition.Entity) && Transform(spectatorPosition.Entity).MapID != MapId.Nullspace) + { + _player.LocalPlayer!.AttachEntity(spectatorPosition.Entity, EntityManager, _client); + return; + } + + if (spectatorPosition.Local != null && spectatorPosition.Local.Value.Coords.IsValid(EntityManager)) + { + var newXform = SpawnSpectatorGhost(spectatorPosition.Local.Value.Coords, false); + newXform.LocalRotation = spectatorPosition.Local.Value.Rot; + } + else if (spectatorPosition.World != null && spectatorPosition.World.Value.Coords.IsValid(EntityManager)) + { + var newXform = SpawnSpectatorGhost(spectatorPosition.World.Value.Coords, true); + newXform.LocalRotation = spectatorPosition.World.Value.Rot; + } + else if (TryFindFallbackSpawn(out var coords)) + { + var newXform = SpawnSpectatorGhost(coords, true); + newXform.LocalRotation = 0; + } + else + { + Logger.Error("Failed to find a suitable observer spawn point"); + return; + } + + if (spectatorPosition.Eye != null && TryComp(_player.LocalPlayer?.ControlledEntity, out InputMoverComponent? newMover)) + { + newMover.RelativeEntity = spectatorPosition.Eye.Value.Ent; + newMover.TargetRelativeRotation = newMover.RelativeRotation = spectatorPosition.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; + } + + 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; + + SpawnSpectatorGhost(new EntityCoordinates(xform.MapUid.Value, default), true); + } + + private void OnParentChanged(EntityUid uid, ReplaySpectatorComponent component, ref EntParentChangedMessage args) + { + if (uid != _player.LocalPlayer?.ControlledEntity) + return; + + if (args.Transform.MapUid != null || args.OldMapId == MapId.Nullspace) + return; + + // The entity being spectated from was moved to null-space. + // This was probably because they were spectating some entity in a client-side replay that left PVS range. + // Simple respawn the ghost. + SetSpectatorPosition(default); + } + + private void OnDetached(EntityUid uid, ReplaySpectatorComponent component, PlayerDetachedEvent args) + { + if (uid.IsClientSide()) + QueueDel(uid); + else + RemCompDeferred(uid, component); + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Spectate.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Spectate.cs new file mode 100644 index 0000000000..0d4775a498 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Spectate.cs @@ -0,0 +1,124 @@ +using Content.Client.Replay.UI; +using Content.Shared.Verbs; +using Robust.Shared.Console; +using Robust.Shared.Map; +using Robust.Shared.Utility; + +namespace Content.Client.Replay.Spectator; + +// This partial class has methods for spawning a spectator ghost and "possessing" entitites. +public sealed partial class ReplaySpectatorSystem +{ + private void OnGetAlternativeVerbs(GetVerbsEvent ev) + { + if (_replayPlayback.Replay == null) + return; + + var verb = new AlternativeVerb + { + Priority = 100, + Act = () => + { + SpectateEntity(ev.Target); + }, + + Text = Loc.GetString("replay-verb-spectate"), + 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 + SpawnSpectatorGhost(Transform(target).Coordinates, true); + return; + } + + _player.LocalPlayer.AttachEntity(target, EntityManager, _client); + EnsureComp(target); + + _stateMan.RequestStateChange(); + if (old == null) + return; + + if (old.Value.IsClientSide()) + Del(old.Value); + else + RemComp(old.Value); + } + + public TransformComponent SpawnSpectatorGhost(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 SpectateCommand(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length == 0) + { + if (_player.LocalPlayer?.ControlledEntity is { } current) + SpawnSpectatorGhost(new EntityCoordinates(current, default), true); + else + SpawnSpectatorGhost(default, true); + return; + } + + if (!EntityUid.TryParse(args[0], out var uid)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-uid", ("arg", args[0]))); + return; + } + + if (!Exists(uid)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-entity-exist", ("arg", args[0]))); + return; + } + + SpectateEntity(uid); + } + + private CompletionResult SpectateCompletions(IConsoleShell shell, string[] args) + { + if (args.Length != 1) + return CompletionResult.Empty; + + return CompletionResult.FromHintOptions(CompletionHelper.EntityUids(args[0], + EntityManager), Loc.GetString("cmd-replay-spectate-hint")); + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs index 40f0fd1880..add786544e 100644 --- a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs @@ -1,6 +1,3 @@ -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; @@ -9,9 +6,6 @@ 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; @@ -35,7 +29,8 @@ public sealed partial class ReplaySpectatorSystem : EntitySystem [Dependency] private readonly SharedContentEyeSystem _eye = default!; [Dependency] private readonly IReplayPlaybackManager _replayPlayback = default!; - private ObserverData? _oldPosition; + private SpectatorPosition? _oldPosition; + public const string SpectateCmd = "replay_spectate"; public override void Initialize() { @@ -44,9 +39,9 @@ public sealed partial class ReplaySpectatorSystem : EntitySystem SubscribeLocalEvent>(OnGetAlternativeVerbs); SubscribeLocalEvent(OnTerminating); SubscribeLocalEvent(OnDetached); + SubscribeLocalEvent(OnParentChanged); InitializeBlockers(); - _conHost.RegisterCommand("observe", ObserveCommand); _replayPlayback.BeforeSetTick += OnBeforeSetTick; _replayPlayback.AfterSetTick += OnAfterSetTick; @@ -54,228 +49,29 @@ public sealed partial class ReplaySpectatorSystem : EntitySystem _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 OnPlaybackStarted() + { + InitializeMovement(); + SetSpectatorPosition(default); + _conHost.RegisterCommand(SpectateCmd, + Loc.GetString("cmd-replay-spectate-desc"), + Loc.GetString("cmd-replay-spectate-help"), + SpectateCommand, + SpectateCompletions); + } + 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); + _conHost.UnregisterCommand(SpectateCmd); } } diff --git a/Resources/Locale/en-US/replays/replays.ftl b/Resources/Locale/en-US/replays/replays.ftl index 29b0a372f3..560285cbb1 100644 --- a/Resources/Locale/en-US/replays/replays.ftl +++ b/Resources/Locale/en-US/replays/replays.ftl @@ -19,7 +19,7 @@ replay-menu-none = No replays found. # Main Menu Info Box replay-info-title = Replay Information replay-info-none-selected = No replay selected -replay-info-invalid = [color=red]Invalid replay selected[/color] +replay-info-invalid = [color=red]Invalid replay selected[/color] replay-info-info = {"["}color=gray]Selected:[/color] {$name} ({$file}) {"["}color=gray]Time:[/color] {$time} {"["}color=gray]Round ID:[/color] {$roundId} @@ -32,3 +32,11 @@ replay-info-info = {"["}color=gray]Selected:[/color] {$name} ({$file}) # Replay selection window replay-menu-select-title = Select Replay + +# Replay related verbs +replay-verb-spectate = Spectate + +# command +cmd-replay-spectate-help = replay_spectate [optional entity] +cmd-replay-spectate-desc = Attaches or detaches the local player to a given entity uid. +cmd-replay-spectate-hint = Optional EntityUid