Replay client (#15001)
This commit is contained in:
@@ -14,6 +14,7 @@ using Content.Client.Parallax.Managers;
|
|||||||
using Content.Client.Players.PlayTimeTracking;
|
using Content.Client.Players.PlayTimeTracking;
|
||||||
using Content.Client.Preferences;
|
using Content.Client.Preferences;
|
||||||
using Content.Client.Radiation.Overlays;
|
using Content.Client.Radiation.Overlays;
|
||||||
|
using Content.Client.Replay;
|
||||||
using Content.Client.Screenshot;
|
using Content.Client.Screenshot;
|
||||||
using Content.Client.Singularity;
|
using Content.Client.Singularity;
|
||||||
using Content.Client.Stylesheets;
|
using Content.Client.Stylesheets;
|
||||||
@@ -62,6 +63,7 @@ namespace Content.Client.Entry
|
|||||||
[Dependency] private readonly ExtendedDisconnectInformationManager _extendedDisconnectInformation = default!;
|
[Dependency] private readonly ExtendedDisconnectInformationManager _extendedDisconnectInformation = default!;
|
||||||
[Dependency] private readonly JobRequirementsManager _jobRequirements = default!;
|
[Dependency] private readonly JobRequirementsManager _jobRequirements = default!;
|
||||||
[Dependency] private readonly ContentLocalizationManager _contentLoc = default!;
|
[Dependency] private readonly ContentLocalizationManager _contentLoc = default!;
|
||||||
|
[Dependency] private readonly ContentReplayPlaybackManager _playbackMan = default!;
|
||||||
|
|
||||||
public override void Init()
|
public override void Init()
|
||||||
{
|
{
|
||||||
@@ -131,6 +133,7 @@ namespace Content.Client.Entry
|
|||||||
_ghostKick.Initialize();
|
_ghostKick.Initialize();
|
||||||
_extendedDisconnectInformation.Initialize();
|
_extendedDisconnectInformation.Initialize();
|
||||||
_jobRequirements.Initialize();
|
_jobRequirements.Initialize();
|
||||||
|
_playbackMan.Initialize();
|
||||||
|
|
||||||
//AUTOSCALING default Setup!
|
//AUTOSCALING default Setup!
|
||||||
_configManager.SetCVar("interface.resolutionAutoScaleUpperCutoffX", 1080);
|
_configManager.SetCVar("interface.resolutionAutoScaleUpperCutoffX", 1080);
|
||||||
@@ -154,7 +157,6 @@ namespace Content.Client.Entry
|
|||||||
_overlayManager.AddOverlay(new SingularityOverlay());
|
_overlayManager.AddOverlay(new SingularityOverlay());
|
||||||
_overlayManager.AddOverlay(new FlashOverlay());
|
_overlayManager.AddOverlay(new FlashOverlay());
|
||||||
_overlayManager.AddOverlay(new RadiationPulseOverlay());
|
_overlayManager.AddOverlay(new RadiationPulseOverlay());
|
||||||
|
|
||||||
_chatManager.Initialize();
|
_chatManager.Initialize();
|
||||||
_clientPreferencesManager.Initialize();
|
_clientPreferencesManager.Initialize();
|
||||||
_euiManager.Initialize();
|
_euiManager.Initialize();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ using Content.Shared.Administration;
|
|||||||
using Content.Shared.Administration.Logs;
|
using Content.Shared.Administration.Logs;
|
||||||
using Content.Shared.Module;
|
using Content.Shared.Module;
|
||||||
using Content.Client.Guidebook;
|
using Content.Client.Guidebook;
|
||||||
|
using Content.Client.Replay;
|
||||||
using Content.Shared.Administration.Managers;
|
using Content.Shared.Administration.Managers;
|
||||||
|
|
||||||
namespace Content.Client.IoC
|
namespace Content.Client.IoC
|
||||||
@@ -44,6 +45,7 @@ namespace Content.Client.IoC
|
|||||||
IoCManager.Register<ExtendedDisconnectInformationManager>();
|
IoCManager.Register<ExtendedDisconnectInformationManager>();
|
||||||
IoCManager.Register<JobRequirementsManager>();
|
IoCManager.Register<JobRequirementsManager>();
|
||||||
IoCManager.Register<DocumentParsingManager>();
|
IoCManager.Register<DocumentParsingManager>();
|
||||||
|
IoCManager.Register<ContentReplayPlaybackManager, ContentReplayPlaybackManager>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
Content.Client/Replay/ContentReplayPlaybackManager.cs
Normal file
143
Content.Client/Replay/ContentReplayPlaybackManager.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Content.Client/Replay/LoadReplayJob.cs
Normal file
39
Content.Client/Replay/LoadReplayJob.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Content.Client/Replay/ReplayConGroup.cs
Normal file
13
Content.Client/Replay/ReplayConGroup.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
281
Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs
Normal file
281
Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Content.Client/Replay/UI/Loading/LoadingScreen.cs
Normal file
51
Content.Client/Replay/UI/Loading/LoadingScreen.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml
Normal file
38
Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml
Normal 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>
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Content.Client/Replay/UI/ReplayGhostState.cs
Normal file
40
Content.Client/Replay/UI/ReplayGhostState.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Content.Client/Replay/UI/ReplaySpectateEntityState.cs
Normal file
48
Content.Client/Replay/UI/ReplaySpectateEntityState.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Content.Replay/Content.Replay.csproj
Normal file
26
Content.Replay/Content.Replay.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>$(TargetFramework)</TargetFramework>
|
||||||
|
<LangVersion>10</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<OutputPath>..\bin\Content.Replay\</OutputPath>
|
||||||
|
<OutputType Condition="'$(FullRelease)' != 'True'">Exe</OutputType>
|
||||||
|
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Nett" Version="0.15.0" />
|
||||||
|
<PackageReference Include="JetBrains.Annotations" Version="2020.1.0" PrivateAssets="All" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\RobustToolbox\Lidgren.Network\Lidgren.Network.csproj" />
|
||||||
|
<ProjectReference Include="..\RobustToolbox\Robust.Shared.Maths\Robust.Shared.Maths.csproj" />
|
||||||
|
<ProjectReference Include="..\RobustToolbox\Robust.Shared\Robust.Shared.csproj" />
|
||||||
|
<ProjectReference Include="..\RobustToolbox\Robust.Client\Robust.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\Content.Shared\Content.Shared.csproj" />
|
||||||
|
<ProjectReference Include="..\Content.Client\Content.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Import Project="..\RobustToolbox\MSBuild\XamlIL.targets" />
|
||||||
|
<Import Project="..\RobustToolbox\MSBuild\Robust.Properties.targets" />
|
||||||
|
</Project>
|
||||||
34
Content.Replay/EntryPoint.cs
Normal file
34
Content.Replay/EntryPoint.cs
Normal file
@@ -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<ReplayMainScreen>();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Content.Replay/GlobalUsings.cs
Normal file
9
Content.Replay/GlobalUsings.cs
Normal file
@@ -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;
|
||||||
283
Content.Replay/Menu/ReplayMainMenu.cs
Normal file
283
Content.Replay/Menu/ReplayMainMenu.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main menu screen for selecting and loading replays.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read replay meta-data and update the replay info box.
|
||||||
|
/// </summary>
|
||||||
|
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<ValueDataNode>(Time, out var timeNode);
|
||||||
|
data.TryGet<ValueDataNode>(Duration, out var durationNode);
|
||||||
|
data.TryGet<ValueDataNode>("roundId", out var roundIdNode);
|
||||||
|
data.TryGet<ValueDataNode>(Hash, out var hashNode);
|
||||||
|
data.TryGet<ValueDataNode>(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<ValueDataNode>(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<ValueDataNode>(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<ValueDataNode>(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<ValueDataNode>(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<ValueDataNode>(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<OptionsUIController>().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();
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Content.Replay/Menu/ReplayMainMenuControl.xaml
Normal file
52
Content.Replay/Menu/ReplayMainMenuControl.xaml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<Control xmlns="https://spacestation14.io"
|
||||||
|
xmlns:pllax="clr-namespace:Content.Client.Parallax;assembly=Content.Client"
|
||||||
|
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls;assembly=Content.Client"
|
||||||
|
xmlns:style="clr-namespace:Content.Client.Stylesheets;assembly=Content.Client"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<pllax:ParallaxControl />
|
||||||
|
<LayoutContainer>
|
||||||
|
<BoxContainer Name="VBox" Orientation="Vertical" StyleIdentifier="mainMenuVBox">
|
||||||
|
<TextureRect Name="Logo" Stretch="KeepCentered" />
|
||||||
|
<Label Name="Subtext" Access="Public" Text="{Loc 'replay-menu-subtext'}"/>
|
||||||
|
<Button Name="LoadButton"
|
||||||
|
Access="Public"
|
||||||
|
Text="{Loc 'replay-menu-load'}"
|
||||||
|
TextAlign="Center"
|
||||||
|
StyleIdentifier="mainMenu"
|
||||||
|
Disabled="True"/>
|
||||||
|
<Button Name="SelectButton"
|
||||||
|
Access="Public"
|
||||||
|
Text="{Loc 'replay-menu-select'}"
|
||||||
|
TextAlign="Center"
|
||||||
|
StyleIdentifier="mainMenu"/>
|
||||||
|
<Button Name="FolderButton"
|
||||||
|
Access="Public"
|
||||||
|
Text="{Loc 'replay-menu-open'}"
|
||||||
|
TextAlign="Center"
|
||||||
|
StyleIdentifier="mainMenu" />
|
||||||
|
<Control MinSize="0 2" />
|
||||||
|
<Button Name="OptionsButton"
|
||||||
|
Access="Public"
|
||||||
|
Text="{Loc 'main-menu-options-button'}"
|
||||||
|
TextAlign="Center"
|
||||||
|
StyleIdentifier="mainMenu" />
|
||||||
|
<Button Name="QuitButton"
|
||||||
|
Access="Public"
|
||||||
|
Text="{Loc 'main-menu-quit-button'}"
|
||||||
|
TextAlign="Center"
|
||||||
|
StyleIdentifier="mainMenu" />
|
||||||
|
</BoxContainer>
|
||||||
|
<PanelContainer Name="InfoContainer"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Access="Public"
|
||||||
|
Margin="16"
|
||||||
|
MinSize="300 150">
|
||||||
|
<BoxContainer Orientation="Vertical" Align="Begin" Margin="8">
|
||||||
|
<Label Text="{Loc 'replay-info-title'}" Margin="4" HorizontalAlignment="Center"/>
|
||||||
|
<controls:HLine Color="{x:Static style:StyleNano.NanoGold}" Thickness="4"/>
|
||||||
|
<RichTextLabel Access="Public" Name="Info"/>
|
||||||
|
</BoxContainer>
|
||||||
|
</PanelContainer>
|
||||||
|
</LayoutContainer>
|
||||||
|
</Control>
|
||||||
39
Content.Replay/Menu/ReplayMainMenuControl.xaml.cs
Normal file
39
Content.Replay/Menu/ReplayMainMenuControl.xaml.cs
Normal 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;
|
||||||
|
|
||||||
|
namespace Content.Replay.Menu;
|
||||||
|
|
||||||
|
[GenerateTypedNameReferences]
|
||||||
|
public sealed partial class ReplayMainMenuControl : Control
|
||||||
|
{
|
||||||
|
public ReplayMainMenuControl(IResourceCache resCache)
|
||||||
|
{
|
||||||
|
RobustXamlLoader.Load(this);
|
||||||
|
|
||||||
|
LayoutContainer.SetAnchorPreset(this, LayoutContainer.LayoutPreset.Wide);
|
||||||
|
|
||||||
|
LayoutContainer.SetAnchorPreset(VBox, LayoutContainer.LayoutPreset.TopRight);
|
||||||
|
LayoutContainer.SetMarginRight(VBox, -25);
|
||||||
|
LayoutContainer.SetMarginTop(VBox, 30);
|
||||||
|
LayoutContainer.SetGrowHorizontal(VBox, LayoutContainer.GrowDirection.Begin);
|
||||||
|
|
||||||
|
Subtext.FontOverride = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 24);
|
||||||
|
var logoTexture = resCache.GetResource<TextureResource>("/Textures/Logo/logo.png");
|
||||||
|
Logo.Texture = logoTexture;
|
||||||
|
|
||||||
|
LayoutContainer.SetAnchorPreset(InfoContainer, LayoutContainer.LayoutPreset.BottomLeft);
|
||||||
|
LayoutContainer.SetGrowHorizontal(InfoContainer, LayoutContainer.GrowDirection.End);
|
||||||
|
LayoutContainer.SetGrowVertical(InfoContainer, LayoutContainer.GrowDirection.Begin);
|
||||||
|
InfoContainer.PanelOverride = new StyleBoxFlat()
|
||||||
|
{
|
||||||
|
BackgroundColor = Color.FromHex("#303033"),
|
||||||
|
BorderColor = Color.FromHex("#5a5a5a"),
|
||||||
|
BorderThickness = new Thickness(4)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Content.Replay/Menu/SelectReplayWindow.xaml
Normal file
9
Content.Replay/Menu/SelectReplayWindow.xaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<DefaultWindow xmlns="https://spacestation14.io"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="{Loc 'replay-menu-select-title'}"
|
||||||
|
SetSize="300 200">
|
||||||
|
<BoxContainer Name="VBox" Orientation="Vertical" StyleIdentifier="mainMenuVBox">
|
||||||
|
<Label Name="NoneLabel" Access="Public" Text="{Loc 'replay-menu-none'}" Margin="4" Visible="False"/>
|
||||||
|
<ItemList Name="ReplayList" SelectMode="Single" VerticalExpand="True" Margin ="4" Visible="False"/>
|
||||||
|
</BoxContainer>
|
||||||
|
</DefaultWindow>
|
||||||
61
Content.Replay/Menu/SelectReplayWindow.xaml.cs
Normal file
61
Content.Replay/Menu/SelectReplayWindow.xaml.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using Robust.Client.AutoGenerated;
|
||||||
|
using Robust.Client.UserInterface.Controls;
|
||||||
|
using Robust.Client.UserInterface.CustomControls;
|
||||||
|
using Robust.Client.UserInterface.XAML;
|
||||||
|
using Robust.Shared.Utility;
|
||||||
|
|
||||||
|
namespace Content.Replay.Menu;
|
||||||
|
|
||||||
|
[GenerateTypedNameReferences]
|
||||||
|
public sealed partial class SelectReplayWindow : DefaultWindow
|
||||||
|
{
|
||||||
|
private readonly ReplayMainScreen _screen;
|
||||||
|
|
||||||
|
public SelectReplayWindow(ReplayMainScreen screen)
|
||||||
|
{
|
||||||
|
RobustXamlLoader.Load(this);
|
||||||
|
_screen = screen;
|
||||||
|
ReplayList.OnItemSelected += OnItemSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnItemSelected(ItemList.ItemListSelectedEventArgs obj)
|
||||||
|
{
|
||||||
|
var path = (ResPath?) obj.ItemList[obj.ItemIndex].Metadata;
|
||||||
|
_screen.SelectReplay(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Repopulate(List<(string Name, ResPath Path)> replays)
|
||||||
|
{
|
||||||
|
ReplayList.Clear();
|
||||||
|
foreach (var (name, path) in replays)
|
||||||
|
{
|
||||||
|
ReplayList.AddItem(name).Metadata = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replays.Count > 0)
|
||||||
|
{
|
||||||
|
NoneLabel.Visible = false;
|
||||||
|
ReplayList.Visible = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NoneLabel.Visible = true;
|
||||||
|
ReplayList.Visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateSelected(ResPath? replay)
|
||||||
|
{
|
||||||
|
if (replay == null)
|
||||||
|
{
|
||||||
|
ReplayList.ClearSelected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in ReplayList)
|
||||||
|
{
|
||||||
|
var path = (ResPath?) item.Metadata;
|
||||||
|
item.Selected = path == replay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Content.Replay/Program.cs
Normal file
19
Content.Replay/Program.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using Robust.Client;
|
||||||
|
|
||||||
|
namespace Content.Replay;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
ContentStart.StartLibrary(args, new GameControllerOptions()
|
||||||
|
{
|
||||||
|
Sandboxing = true,
|
||||||
|
ContentModulePrefix = "Content.",
|
||||||
|
ContentBuildDirectory = "Content.Replay",
|
||||||
|
DefaultWindowTitle = "SS14 Replay",
|
||||||
|
UserDataDirectoryName = "Space Station 14",
|
||||||
|
ConfigFileName = "replay.toml"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Resources/Locale/en-US/replays/replays.ftl
Normal file
34
Resources/Locale/en-US/replays/replays.ftl
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Loading Screen
|
||||||
|
|
||||||
|
replay-loading = Loading ({$cur}/{$total})
|
||||||
|
replay-loading-reading = Reading Files
|
||||||
|
replay-loading-processing = Processing Files
|
||||||
|
replay-loading-spawning = Spawning Entities
|
||||||
|
replay-loading-initializing = Initializing Entities
|
||||||
|
replay-loading-starting= Starting Entities
|
||||||
|
replay-loading-failed = Failed to load replay:
|
||||||
|
{$reason}
|
||||||
|
|
||||||
|
# Main Menu
|
||||||
|
replay-menu-subtext = Replay Client
|
||||||
|
replay-menu-load = Load Selected Replay
|
||||||
|
replay-menu-select = Select a Replay
|
||||||
|
replay-menu-open = Open Replay Folder
|
||||||
|
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-info = {"["}color=gray]Selected:[/color] {$name} ({$file})
|
||||||
|
{"["}color=gray]Time:[/color] {$time}
|
||||||
|
{"["}color=gray]Round ID:[/color] {$roundId}
|
||||||
|
{"["}color=gray]Duration:[/color] {$duration}
|
||||||
|
{"["}color=gray]ForkId:[/color] {$forkId}
|
||||||
|
{"["}color=gray]Version:[/color] {$version}
|
||||||
|
{"["}color=gray]Engine:[/color] {$engVersion}
|
||||||
|
{"["}color=gray]Type Hash:[/color] {$hash}
|
||||||
|
{"["}color=gray]Comp Hash:[/color] {$compHash}
|
||||||
|
|
||||||
|
# Replay selection window
|
||||||
|
replay-menu-select-title = Select Replay
|
||||||
BIN
Resources/Textures/Mobs/Silicon/Bots/mommi.rsi/base.png
Normal file
BIN
Resources/Textures/Mobs/Silicon/Bots/mommi.rsi/base.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
26
Resources/Textures/Mobs/Silicon/Bots/mommi.rsi/meta.json
Normal file
26
Resources/Textures/Mobs/Silicon/Bots/mommi.rsi/meta.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"size": {
|
||||||
|
"x": 32,
|
||||||
|
"y": 32
|
||||||
|
},
|
||||||
|
"license": "CC-BY-SA-3.0",
|
||||||
|
"copyright": "Taken from https://github.com/vgstation-coders/vgstation13/blob/760145264ca0d64acb53d76449e0a56630da6753/icons/mob/mommi.dmi",
|
||||||
|
"states": [
|
||||||
|
{
|
||||||
|
"name": "base",
|
||||||
|
"directions": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wiggle",
|
||||||
|
"delays": [
|
||||||
|
[
|
||||||
|
0.3,
|
||||||
|
0.3,
|
||||||
|
0.3,
|
||||||
|
0.3
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
Resources/Textures/Mobs/Silicon/Bots/mommi.rsi/wiggle.png
Normal file
BIN
Resources/Textures/Mobs/Silicon/Bots/mommi.rsi/wiggle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -120,6 +120,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Project Files", "Project Fi
|
|||||||
RobustToolbox\README.md = RobustToolbox\README.md
|
RobustToolbox\README.md = RobustToolbox\README.md
|
||||||
RobustToolbox\RELEASE-NOTES.md = RobustToolbox\RELEASE-NOTES.md
|
RobustToolbox\RELEASE-NOTES.md = RobustToolbox\RELEASE-NOTES.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Content.Replay", "Content.Replay\Content.Replay.csproj", "{A493616C-338D-47B7-8072-A7F14D034D0B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Shared.CompNetworkGenerator", "RobustToolbox\Robust.Shared.CompNetworkGenerator\Robust.Shared.CompNetworkGenerator.csproj", "{07CA34A1-1D37-4771-A2E3-495A1044AE0B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Shared.CompNetworkGenerator", "RobustToolbox\Robust.Shared.CompNetworkGenerator\Robust.Shared.CompNetworkGenerator.csproj", "{07CA34A1-1D37-4771-A2E3-495A1044AE0B}"
|
||||||
EndProject
|
EndProject
|
||||||
@@ -402,6 +403,14 @@ Global
|
|||||||
{424445D4-F5D9-4CA9-A435-0A36E8AA28F3}.DebugOpt|Any CPU.Build.0 = DebugOpt|Any CPU
|
{424445D4-F5D9-4CA9-A435-0A36E8AA28F3}.DebugOpt|Any CPU.Build.0 = DebugOpt|Any CPU
|
||||||
{424445D4-F5D9-4CA9-A435-0A36E8AA28F3}.Tools|Any CPU.ActiveCfg = Tools|Any CPU
|
{424445D4-F5D9-4CA9-A435-0A36E8AA28F3}.Tools|Any CPU.ActiveCfg = Tools|Any CPU
|
||||||
{424445D4-F5D9-4CA9-A435-0A36E8AA28F3}.Tools|Any CPU.Build.0 = Tools|Any CPU
|
{424445D4-F5D9-4CA9-A435-0A36E8AA28F3}.Tools|Any CPU.Build.0 = Tools|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.DebugOpt|Any CPU.ActiveCfg = DebugOpt|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.DebugOpt|Any CPU.Build.0 = DebugOpt|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.Tools|Any CPU.ActiveCfg = Tools|Any CPU
|
||||||
|
{A493616C-338D-47B7-8072-A7F14D034D0B}.Tools|Any CPU.Build.0 = Tools|Any CPU
|
||||||
{07CA34A1-1D37-4771-A2E3-495A1044AE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{07CA34A1-1D37-4771-A2E3-495A1044AE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{07CA34A1-1D37-4771-A2E3-495A1044AE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{07CA34A1-1D37-4771-A2E3-495A1044AE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{07CA34A1-1D37-4771-A2E3-495A1044AE0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{07CA34A1-1D37-4771-A2E3-495A1044AE0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
|||||||
Reference in New Issue
Block a user