Replay client (#15001)

This commit is contained in:
Leon Friedrich
2023-06-05 16:44:09 +12:00
committed by GitHub
parent a8eee5878a
commit 2ef95a3225
28 changed files with 1474 additions and 1 deletions

View File

@@ -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();

View File

@@ -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<ExtendedDisconnectInformationManager>();
IoCManager.Register<JobRequirementsManager>();
IoCManager.Register<DocumentParsingManager>();
IoCManager.Register<ContentReplayPlaybackManager, ContentReplayPlaybackManager>();
}
}
}

View File

@@ -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!;
/// <summary>
/// UI state to return to when stopping a replay or loading fails.
/// </summary>
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<LoadingScreen<bool>>();
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<LauncherConnecting>().SetDisconnected();
else
_stateMan.RequestStateChange<MainScreen>();
}
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<ChatUIController>().History.RemoveAll(x => x.Item1 > _timing.CurTick);
_uiMan.GetUIController<ChatUIController>().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<ChatUIController>().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();
}
}

View File

@@ -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<bool> _screen;
public ContentLoadReplayJob(
float maxTime,
IWritableDirProvider dir,
ResPath path,
IReplayLoadManager loadMan,
LoadingScreen<bool> 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);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
namespace Content.Client.Replay.Spectator;
/// <summary>
/// This component indicates that this entity currently has a replay spectator/observer attached to it.
/// </summary>
[RegisterComponent]
public sealed class ReplaySpectatorComponent : Component
{
}

View File

@@ -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<ReplaySpectatorComponent, UseAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, PickupAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, ThrowAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, InteractionAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, AttackAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, DropAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, IsEquippingAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, IsUnequippingAttemptEvent>(OnAttempt);
SubscribeLocalEvent<ReplaySpectatorComponent, UpdateCanMoveEvent>(OnUpdateCanMove);
SubscribeLocalEvent<ReplaySpectatorComponent, ChangeDirectionAttemptEvent>(OnUpdateCanMove);
SubscribeLocalEvent<ReplaySpectatorComponent, PullAttemptEvent>(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;
}
}

View File

@@ -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;
/// <summary>
/// Fallback speed if the observer ghost has no <see cref="MovementSpeedModifierComponent"/>.
/// </summary>
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<ReplaySpectatorSystem>();
}
private void ShutdownMovement()
{
CommandBinds.Unregister<ReplaySpectatorSystem>();
}
// 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<ReplaySpectatorComponent>(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<TransformComponent>();
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<MovementSpeedModifierComponent>(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;
}
}
}

View File

@@ -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;
/// <summary>
/// This system handles spawning replay observer ghosts and maintaining their positions when traveling through time.
/// It also blocks most normal interactions, just in case.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<GetVerbsEvent<AlternativeVerb>>(OnGetAlternativeVerbs);
SubscribeLocalEvent<ReplaySpectatorComponent, EntityTerminatingEvent>(OnTerminating);
SubscribeLocalEvent<ReplaySpectatorComponent, PlayerDetachedEvent>(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<MapGridComponent>().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<AlternativeVerb> 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<ReplaySpectatorComponent>(target);
if (old == null)
return;
if (old.Value.IsClientSide())
Del(old.Value);
else
RemComp<ReplaySpectatorComponent>(old.Value);
_stateMan.RequestStateChange<ReplaySpectateEntityState>();
}
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<ReplaySpectatorComponent>(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<ReplaySpectatorComponent>(old.Value);
}
_stateMan.RequestStateChange<ReplayGhostState>();
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);
}
}

View File

@@ -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<TResult> : State
{
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!;
public event Action<TResult?, Exception?>? OnJobFinished;
private LoadingScreenControl _screen = default!;
public Job<TResult>? 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;
}
}

View File

@@ -0,0 +1,38 @@
<Control xmlns="https://spacestation14.io"
xmlns:pllax="clr-namespace:Content.Client.Parallax">
<pllax:ParallaxControl />
<PanelContainer Name="Background"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<BoxContainer Orientation="Vertical"
Align="Center">
<BoxContainer
Orientation="Horizontal"
Align="Center"
Margin="12">
<AnimatedTextureRect Name="SpriteLeft"
SetSize="64 64"
Margin="16 0"/>
<Label Name="Header"
Margin="5"
Access="Public"/>
<AnimatedTextureRect Name="SpriteRight"
SetSize="64 64"
Margin="16 0"/>
</BoxContainer>
<ProgressBar Name="Bar"
MaxValue="1.0"
MinWidth="400"
MinHeight="25"
Margin="12 6"
Access="Public"/>
<BoxContainer
Orientation="Horizontal"
Align="Center">
<Label Name="Subtext"
Margin="6"
Access="Public"/>
</BoxContainer>
</BoxContainer>
</PanelContainer>
</Control>

View File

@@ -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)
};
}
}

View File

@@ -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;
/// <summary>
/// Gameplay state when moving around a replay as a ghost.
/// </summary>
public sealed class ReplayGhostState : ReplaySpectateEntityState
{
protected override void Startup()
{
base.Startup();
var screen = UserInterfaceManager.ActiveScreen;
if (screen == null)
return;
screen.ShowWidget<GhostGui>(false);
screen.ShowWidget<ActionsBar>(false);
screen.ShowWidget<AlertsUI>(false);
screen.ShowWidget<HotbarGui>(false);
}
protected override void Shutdown()
{
var screen = UserInterfaceManager.ActiveScreen;
if (screen != null)
{
screen.ShowWidget<GhostGui>(true);
screen.ShowWidget<ActionsBar>(true);
screen.ShowWidget<AlertsUI>(true);
screen.ShowWidget<HotbarGui>(true);
}
base.Shutdown();
}
}

View File

@@ -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;
/// <summary>
/// Gameplay state when observing/spectating an entity during a replay.
/// </summary>
[Virtual]
public class ReplaySpectateEntityState : GameplayState
{
protected override void Startup()
{
base.Startup();
var screen = UserInterfaceManager.ActiveScreen;
if (screen == null)
return;
screen.ShowWidget<GameTopMenuBar>(false);
SetAnchorAndMarginPreset(screen.GetOrAddWidget<ReplayControlWidget>(), LayoutPreset.TopLeft, margin: 10);
foreach (var chatbox in UserInterfaceManager.GetUIController<ChatUIController>().Chats)
{
chatbox.ChatInput.Visible = false;
}
}
protected override void Shutdown()
{
var screen = UserInterfaceManager.ActiveScreen;
if (screen != null)
{
screen.RemoveWidget<ReplayControlWidget>();
screen.ShowWidget<GameTopMenuBar>(true);
}
foreach (var chatbox in UserInterfaceManager.GetUIController<ChatUIController>().Chats)
{
chatbox.ChatInput.Visible = true;
}
base.Shutdown();
}
}