diff --git a/.envrc b/.envrc index 3550a30f2d..5def8fd66a 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,4 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.3.0; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.3.0/direnvrc" "sha256-Dmd+j63L84wuzgyjITIfSxSD57Tx7v51DMxVZOsiUD8=" +fi use flake diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 627126c918..be6c490f74 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,7 +7,7 @@ /Content.*/Station/ @moonheart08 /Content.*/Maps/ @moonheart08 /Content.*/GameTicking/ @moonheart08 -/Resources/Server\ Info/ @moonheart08 +/Resources/ServerInfo/ @moonheart08 /Resources/engineCommandPerms.yml @moonheart08 /Resources/clientCommandPerms.yml @moonheart08 /Resources/Prototypes/species.yml @moonheart08 diff --git a/Content.Client/Actions/ActionsSystem.cs b/Content.Client/Actions/ActionsSystem.cs index 08cdb8c832..3eac41d835 100644 --- a/Content.Client/Actions/ActionsSystem.cs +++ b/Content.Client/Actions/ActionsSystem.cs @@ -173,7 +173,7 @@ public void UnlinkAllActions() public void LinkAllActions(ActionsComponent? actions = null) { var player = _playerManager.LocalPlayer?.ControlledEntity; - if (player == null || !Resolve(player.Value, ref actions)) + if (player == null || !Resolve(player.Value, ref actions, false)) { return; } diff --git a/Content.Client/Chemistry/EntitySystems/ChemistryGuideDataSystem.cs b/Content.Client/Chemistry/EntitySystems/ChemistryGuideDataSystem.cs new file mode 100644 index 0000000000..736e22f834 --- /dev/null +++ b/Content.Client/Chemistry/EntitySystems/ChemistryGuideDataSystem.cs @@ -0,0 +1,29 @@ +using Content.Shared.Chemistry; + +namespace Content.Client.Chemistry.EntitySystems; + +/// +public sealed class ChemistryGuideDataSystem : SharedChemistryGuideDataSystem +{ + /// + public override void Initialize() + { + base.Initialize(); + + SubscribeNetworkEvent(OnReceiveRegistryUpdate); + } + + private void OnReceiveRegistryUpdate(ReagentGuideRegistryChangedEvent message) + { + var data = message.Changeset; + foreach (var remove in data.Removed) + { + Registry.Remove(remove); + } + + foreach (var (key, val) in data.GuideEntries) + { + Registry[key] = val; + } + } +} diff --git a/Content.Client/Clothing/ClientClothingSystem.cs b/Content.Client/Clothing/ClientClothingSystem.cs index 22d639fc2f..81220e650d 100644 --- a/Content.Client/Clothing/ClientClothingSystem.cs +++ b/Content.Client/Clothing/ClientClothingSystem.cs @@ -60,26 +60,15 @@ public override void Initialize() private void OnAppearanceUpdate(EntityUid uid, InventoryComponent component, ref AppearanceChangeEvent args) { // May need to update jumpsuit stencils if the sex changed. Also required to properly set the stencil on init - // when sex is first loaded from the profile. - if (!TryComp(uid, out SpriteComponent? sprite) || !sprite.LayerMapTryGet(HumanoidVisualLayers.StencilMask, out var layer)) - return; + // This is when sex is first loaded from the profile. - if (!TryComp(uid, out HumanoidAppearanceComponent? humanoid) - || humanoid.Sex != Sex.Female - || !_inventorySystem.TryGetSlotEntity(uid, "jumpsuit", out var suit, component) - || !TryComp(suit, out ClothingComponent? clothing)) - { - sprite.LayerSetVisible(layer, false); + if (!TryComp(uid, out SpriteComponent? sprite) || + !TryComp(uid, out HumanoidAppearanceComponent? humanoid) || + !_inventorySystem.TryGetSlotEntity(uid, "jumpsuit", out var suit, component) || + !TryComp(suit, out ClothingComponent? clothing)) return; - } - sprite.LayerSetState(layer, clothing.FemaleMask switch - { - FemaleClothingMask.NoMask => "female_none", - FemaleClothingMask.UniformTop => "female_top", - _ => "female_full", - }); - sprite.LayerSetVisible(layer, true); + SetGenderedMask(sprite, humanoid, clothing); } private void OnGetVisuals(EntityUid uid, ClothingComponent item, GetEquipmentVisualsEvent args) @@ -225,20 +214,12 @@ private void RenderEquipment(EntityUid equipee, EntityUid equipment, string slot return; } - if (slot == "jumpsuit" && sprite.LayerMapTryGet(HumanoidVisualLayers.StencilMask, out var suitLayer)) + if (slot == "jumpsuit") { - if (TryComp(equipee, out HumanoidAppearanceComponent? humanoid) && humanoid.Sex == Sex.Female) - { - sprite.LayerSetState(suitLayer, clothingComponent.FemaleMask switch - { - FemaleClothingMask.NoMask => "female_none", - FemaleClothingMask.UniformTop => "female_top", - _ => "female_full", - }); - sprite.LayerSetVisible(suitLayer, true); - } - else - sprite.LayerSetVisible(suitLayer, false); + if (!TryComp(equipee, out HumanoidAppearanceComponent? humanoid)) + return; + + SetGenderedMask(sprite, humanoid, clothingComponent); } if (!_inventorySystem.TryGetSlot(equipee, slot, out var slotDef, inventory)) @@ -315,4 +296,49 @@ private void RenderEquipment(EntityUid equipee, EntityUid equipment, string slot RaiseLocalEvent(equipment, new EquipmentVisualsUpdatedEvent(equipee, slot, revealedLayers), true); } + + + /// + /// Sets a sprite's gendered mask based on gender (obviously). + /// + /// Sprite to modify + /// Humanoid, to get gender from + /// Clothing component, to get mask sprite type + private static void SetGenderedMask(SpriteComponent sprite, HumanoidAppearanceComponent humanoid, ClothingComponent clothing) + { + if (!sprite.LayerMapTryGet(HumanoidVisualLayers.StencilMask, out var layer)) + return; + + ClothingMask? mask = null; + var prefix = ""; + + switch (humanoid.Sex) + { + case Sex.Male: + mask = clothing.MaleMask; + prefix = "male_"; + break; + case Sex.Female: + mask = clothing.FemaleMask; + prefix = "female_"; + break; + case Sex.Unsexed: + mask = clothing.UnisexMask; + prefix = "unisex_"; + break; + } + + if (mask != null) + { + sprite.LayerSetState(layer, mask switch + { + ClothingMask.NoMask => $"{prefix}none", + ClothingMask.UniformTop => $"{prefix}top", + _ => $"{prefix}full", + }); + sprite.LayerSetVisible(layer, true); + } + else + sprite.LayerSetVisible(layer, false); + } } diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index e17b3a6a2f..49383d9779 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -14,6 +14,7 @@ using Content.Client.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 @@ public sealed class EntryPoint : GameClient [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 @@ public override void Init() _ghostKick.Initialize(); _extendedDisconnectInformation.Initialize(); _jobRequirements.Initialize(); + _playbackMan.Initialize(); //AUTOSCALING default Setup! _configManager.SetCVar("interface.resolutionAutoScaleUpperCutoffX", 1080); @@ -154,7 +157,6 @@ public override void PostInit() _overlayManager.AddOverlay(new SingularityOverlay()); _overlayManager.AddOverlay(new FlashOverlay()); _overlayManager.AddOverlay(new RadiationPulseOverlay()); - _chatManager.Initialize(); _clientPreferencesManager.Initialize(); _euiManager.Initialize(); diff --git a/Content.Client/Examine/ExamineSystem.cs b/Content.Client/Examine/ExamineSystem.cs index 0109f90cc5..cd09ff5fd1 100644 --- a/Content.Client/Examine/ExamineSystem.cs +++ b/Content.Client/Examine/ExamineSystem.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Threading; using Content.Shared.Eye.Blinding.Components; +using Robust.Client; using static Content.Shared.Interaction.SharedInteractionSystem; using static Robust.Client.UserInterface.Controls.BoxContainer; @@ -28,6 +29,7 @@ public sealed class ExamineSystem : ExamineSystemShared [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly VerbSystem _verbSystem = default!; + [Dependency] private readonly IBaseClient _client = default!; public const string StyleClassEntityTooltip = "entity-tooltip"; @@ -327,9 +329,9 @@ public void VerbButtonPressed(BaseButton.ButtonEventArgs obj) } } - public void DoExamine(EntityUid entity, bool centeredOnCursor=true) + public void DoExamine(EntityUid entity, bool centeredOnCursor = true, EntityUid? userOverride = null) { - var playerEnt = _playerManager.LocalPlayer?.ControlledEntity; + var playerEnt = userOverride ?? _playerManager.LocalPlayer?.ControlledEntity; if (playerEnt == null) return; @@ -341,10 +343,10 @@ public void DoExamine(EntityUid entity, bool centeredOnCursor=true) canSeeClearly = false; OpenTooltip(playerEnt.Value, entity, centeredOnCursor, false, knowTarget: canSeeClearly); - if (entity.IsClientSide()) + if (entity.IsClientSide() + || _client.RunLevel == ClientRunLevel.SinglePlayerGame) // i.e. a replay { message = GetExamineText(entity, playerEnt); - UpdateTooltipInfo(playerEnt.Value, entity, message); } else diff --git a/Content.Client/GameTicking/Managers/ClientGameTicker.cs b/Content.Client/GameTicking/Managers/ClientGameTicker.cs index f1e29a2ea1..33a20c945a 100644 --- a/Content.Client/GameTicking/Managers/ClientGameTicker.cs +++ b/Content.Client/GameTicking/Managers/ClientGameTicker.cs @@ -21,11 +21,18 @@ public sealed class ClientGameTicker : SharedGameTicker [Dependency] private readonly IStateManager _stateManager = default!; [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] private readonly IConfigurationManager _configManager = default!; + [Dependency] private readonly BackgroundAudioSystem _backgroundAudio = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; [ViewVariables] private bool _initialized; private Dictionary> _jobsAvailable = new(); private Dictionary _stationNames = new(); + /// + /// The current round-end window. Could be used to support re-opening the window after closing it. + /// + private RoundEndSummaryWindow? _window; + [ViewVariables] public bool AreWeReady { get; private set; } [ViewVariables] public bool IsGameStarted { get; private set; } [ViewVariables] public string? LobbySong { get; private set; } @@ -127,13 +134,17 @@ private void RoundEnd(RoundEndMessageEvent message) if (message.LobbySong != null) { LobbySong = message.LobbySong; - Get().StartLobbyMusic(); + _backgroundAudio.StartLobbyMusic(); } RestartSound = message.RestartSound; + // Don't open duplicate windows (mainly for replays). + if (_window?.RoundId == message.RoundId) + return; + //This is not ideal at all, but I don't see an immediately better fit anywhere else. - var roundEnd = new RoundEndSummaryWindow(message.GamemodeTitle, message.RoundEndText, message.RoundDuration, message.RoundId, message.AllPlayersEndInfo, _entityManager); + _window = new RoundEndSummaryWindow(message.GamemodeTitle, message.RoundEndText, message.RoundDuration, message.RoundId, message.AllPlayersEndInfo, _entityManager); } private void RoundRestartCleanup(RoundRestartCleanupEvent ev) @@ -147,7 +158,7 @@ private void RoundRestartCleanup(RoundRestartCleanupEvent ev) return; } - SoundSystem.Play(RestartSound, Filter.Empty()); + _audio.PlayGlobal(RestartSound, Filter.Local(), false); // Cleanup the sound, we only want it to play when the round restarts after it ends normally. RestartSound = null; diff --git a/Content.Client/Gameplay/GameplayState.cs b/Content.Client/Gameplay/GameplayState.cs index 3765753c69..1efee978f3 100644 --- a/Content.Client/Gameplay/GameplayState.cs +++ b/Content.Client/Gameplay/GameplayState.cs @@ -13,7 +13,8 @@ namespace Content.Client.Gameplay { - public sealed class GameplayState : GameplayStateBase, IMainViewportState + [Virtual] + public class GameplayState : GameplayStateBase, IMainViewportState { [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IOverlayManager _overlayManager = default!; diff --git a/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs index 34b4f3bdaa..fc885b3655 100644 --- a/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs +++ b/Content.Client/Guidebook/Controls/GuideEntityEmbed.xaml.cs @@ -79,7 +79,8 @@ protected override void KeyBindDown(GUIBoundKeyEventArgs args) // do examination? if (args.Function == ContentKeyFunctions.ExamineEntity) { - _examineSystem.DoExamine(entity.Value); + _examineSystem.DoExamine(entity.Value, + userOverride: _guidebookSystem.GetGuidebookUser()); args.Handle(); return; } diff --git a/Content.Client/Guidebook/Controls/GuideReagentEmbed.xaml b/Content.Client/Guidebook/Controls/GuideReagentEmbed.xaml new file mode 100644 index 0000000000..e8a36d57f4 --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuideReagentEmbed.xaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/Guidebook/Controls/GuideReagentEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideReagentEmbed.xaml.cs new file mode 100644 index 0000000000..4b832be02b --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuideReagentEmbed.xaml.cs @@ -0,0 +1,176 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Content.Client.Chemistry.EntitySystems; +using Content.Client.Guidebook.Richtext; +using Content.Client.Message; +using Content.Shared.Chemistry.Reaction; +using Content.Shared.Chemistry.Reagent; +using JetBrains.Annotations; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Client.Guidebook.Controls; + +/// +/// Control for embedding a reagent into a guidebook. +/// +[UsedImplicitly, GenerateTypedNameReferences] +public sealed partial class GuideReagentEmbed : BoxContainer, IDocumentTag +{ + [Dependency] private readonly IEntitySystemManager _systemManager = default!; + [Dependency] private readonly IPrototypeManager _prototype = default!; + + private readonly ChemistryGuideDataSystem _chemistryGuideData; + + public GuideReagentEmbed() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + _chemistryGuideData = _systemManager.GetEntitySystem(); + MouseFilter = MouseFilterMode.Stop; + } + + public GuideReagentEmbed(string reagent) : this() + { + GenerateControl(_prototype.Index(reagent)); + } + + public GuideReagentEmbed(ReagentPrototype reagent) : this() + { + GenerateControl(reagent); + } + + public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control) + { + control = null; + if (!args.TryGetValue("Reagent", out var id)) + { + Logger.Error("Reagent embed tag is missing reagent prototype argument"); + return false; + } + + if (!_prototype.TryIndex(id, out var reagent)) + { + Logger.Error($"Specified reagent prototype \"{id}\" is not a valid reagent prototype"); + return false; + } + + GenerateControl(reagent); + + control = this; + return true; + } + + private void GenerateControl(ReagentPrototype reagent) + { + NameBackground.PanelOverride = new StyleBoxFlat + { + BackgroundColor = reagent.SubstanceColor + }; + + var textColor = Color.ToHsl(reagent.SubstanceColor).Z > 0.45 + ? Color.Black + : Color.White; + + ReagentName.SetMarkup(Loc.GetString("guidebook-reagent-name", + ("color", textColor), ("name", reagent.LocalizedName))); + + #region Recipe + // by default, we assume that the reaction has the same ID as the reagent. + // if this isn't true, we'll loop through reactions. + if (!_prototype.TryIndex(reagent.ID, out var reactionPrototype)) + { + reactionPrototype = _prototype.EnumeratePrototypes() + .FirstOrDefault(p => p.Products.ContainsKey(reagent.ID)); + } + + if (reactionPrototype != null) + { + var reactantMsg = new FormattedMessage(); + var reactantsCount = reactionPrototype.Reactants.Count; + var i = 0; + foreach (var (product, reactant) in reactionPrototype.Reactants) + { + reactantMsg.AddMarkup(Loc.GetString("guidebook-reagent-recipes-reagent-display", + ("reagent", _prototype.Index(product).LocalizedName), ("ratio", reactant.Amount))); + i++; + if (i < reactantsCount) + reactantMsg.PushNewline(); + } + reactantMsg.Pop(); + ReactantsLabel.SetMessage(reactantMsg); + + var productMsg = new FormattedMessage(); + var productCount = reactionPrototype.Products.Count; + var u = 0; + foreach (var (product, ratio) in reactionPrototype.Products) + { + productMsg.AddMarkup(Loc.GetString("guidebook-reagent-recipes-reagent-display", + ("reagent", _prototype.Index(product).LocalizedName), ("ratio", ratio))); + u++; + if (u < productCount) + productMsg.PushNewline(); + } + productMsg.Pop(); + ProductsLabel.SetMessage(productMsg); + } + else + { + RecipesContainer.Visible = false; + } + #endregion + + #region Effects + if (_chemistryGuideData.ReagentGuideRegistry.TryGetValue(reagent.ID, out var guideEntryRegistry) && + guideEntryRegistry.GuideEntries != null && + guideEntryRegistry.GuideEntries.Values.Any(pair => pair.EffectDescriptions.Any())) + { + EffectsDescriptionContainer.Children.Clear(); + foreach (var (group, effect) in guideEntryRegistry.GuideEntries) + { + if (!effect.EffectDescriptions.Any()) + continue; + + var groupLabel = new RichTextLabel(); + groupLabel.SetMarkup(Loc.GetString("guidebook-reagent-effects-metabolism-group-rate", + ("group", group), ("rate", effect.MetabolismRate))); + var descriptionLabel = new RichTextLabel + { + Margin = new Thickness(25, 0, 10, 0) + }; + + var descMsg = new FormattedMessage(); + var descriptionsCount = effect.EffectDescriptions.Length; + var i = 0; + foreach (var effectString in effect.EffectDescriptions) + { + descMsg.AddMarkup(effectString); + i++; + if (i < descriptionsCount) + descMsg.PushNewline(); + } + descriptionLabel.SetMessage(descMsg); + + EffectsDescriptionContainer.AddChild(groupLabel); + EffectsDescriptionContainer.AddChild(descriptionLabel); + } + } + else + { + EffectsContainer.Visible = false; + } + #endregion + + FormattedMessage description = new(); + description.AddText(reagent.LocalizedDescription); + description.PushNewline(); + description.AddText(Loc.GetString("guidebook-reagent-physical-description", + ("description", reagent.LocalizedPhysicalDescription))); + ReagentDescription.SetMessage(description); + } +} diff --git a/Content.Client/Guidebook/Controls/GuideReagentGroupEmbed.xaml b/Content.Client/Guidebook/Controls/GuideReagentGroupEmbed.xaml new file mode 100644 index 0000000000..da671adaa7 --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuideReagentGroupEmbed.xaml @@ -0,0 +1,4 @@ + + + diff --git a/Content.Client/Guidebook/Controls/GuideReagentGroupEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideReagentGroupEmbed.xaml.cs new file mode 100644 index 0000000000..0c9356eccb --- /dev/null +++ b/Content.Client/Guidebook/Controls/GuideReagentGroupEmbed.xaml.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Content.Client.Guidebook.Richtext; +using Content.Shared.Chemistry.Reagent; +using JetBrains.Annotations; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Prototypes; + +namespace Content.Client.Guidebook.Controls; + +/// +/// Control for embedding a reagent into a guidebook. +/// +[UsedImplicitly, GenerateTypedNameReferences] +public sealed partial class GuideReagentGroupEmbed : BoxContainer, IDocumentTag +{ + [Dependency] private readonly IPrototypeManager _prototype = default!; + + public GuideReagentGroupEmbed() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + MouseFilter = MouseFilterMode.Stop; + } + + public GuideReagentGroupEmbed(string group) : this() + { + var prototypes = _prototype.EnumeratePrototypes() + .Where(p => p.Group.Equals(group)).OrderBy(p => p.LocalizedName); + foreach (var reagent in prototypes) + { + var embed = new GuideReagentEmbed(reagent); + GroupContainer.AddChild(embed); + } + } + + public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control) + { + control = null; + if (!args.TryGetValue("Group", out var group)) + { + Logger.Error("Reagent group embed tag is missing group argument"); + return false; + } + + var prototypes = _prototype.EnumeratePrototypes() + .Where(p => p.Group.Equals(group)).OrderBy(p => p.LocalizedName); + foreach (var reagent in prototypes) + { + var embed = new GuideReagentEmbed(reagent); + GroupContainer.AddChild(embed); + } + + control = this; + return true; + } +} diff --git a/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs index 32a7e2b070..61054e9b0f 100644 --- a/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs +++ b/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs @@ -1,7 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; +using Content.Client.Guidebook.RichText; using Content.Client.UserInterface.Controls; using Content.Client.UserInterface.Controls.FancyTree; +using JetBrains.Annotations; using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; using Robust.Shared.ContentPack; @@ -9,7 +13,7 @@ namespace Content.Client.Guidebook.Controls; [GenerateTypedNameReferences] -public sealed partial class GuidebookWindow : FancyWindow +public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler { [Dependency] private readonly IResourceManager _resourceManager = default!; [Dependency] private readonly DocumentParsingManager _parsingMan = default!; @@ -139,4 +143,20 @@ private void RepopulateTree(List? roots = null, string? forcedRoot = nul return item; } + + public void HandleClick(string link) + { + if (!_entries.TryGetValue(link, out var entry)) + return; + + if (Tree.TryGetIndexFromMetadata(entry, out var index)) + { + Tree.ExpandParentEntries(index.Value); + Tree.SetSelectedIndex(index); + } + else + { + ShowGuide(entry); + } + } } diff --git a/Content.Client/Guidebook/GuidebookSystem.cs b/Content.Client/Guidebook/GuidebookSystem.cs index acaa51524b..6ed38ddef0 100644 --- a/Content.Client/Guidebook/GuidebookSystem.cs +++ b/Content.Client/Guidebook/GuidebookSystem.cs @@ -9,7 +9,9 @@ using Content.Shared.Verbs; using Robust.Client.GameObjects; using Robust.Client.Player; +using Robust.Shared.Map; using Robust.Shared.Player; +using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Client.Guidebook; @@ -19,6 +21,7 @@ namespace Content.Client.Guidebook; /// public sealed class GuidebookSystem : EntitySystem { + [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly SharedAudioSystem _audioSystem = default!; [Dependency] private readonly VerbSystem _verbSystem = default!; @@ -29,6 +32,8 @@ public sealed class GuidebookSystem : EntitySystem public event Action, List?, string?, bool, string?>? OnGuidebookOpen; public const string GuideEmbedTag = "GuideEmbeded"; + private EntityUid _defaultUser; + /// public override void Initialize() { @@ -41,6 +46,23 @@ public override void Initialize() OnGuidebookControlsTestGetAlternateVerbs); } + /// + /// Gets a user entity to use for verbs and examinations. If the player has no attached entity, this will use a + /// dummy client-side entity so that users can still use the guidebook when not attached to anything (e.g., in the + /// lobby) + /// + public EntityUid GetGuidebookUser() + { + var user = _playerManager.LocalPlayer!.ControlledEntity; + if (user != null) + return user.Value; + + if (!Exists(_defaultUser)) + _defaultUser = Spawn(null, MapCoordinates.Nullspace); + + return _defaultUser; + } + private void OnGetVerbs(EntityUid uid, GuideHelpComponent component, GetVerbsEvent args) { if (component.Guides.Count == 0 || _tags.HasTag(uid, GuideEmbedTag)) @@ -58,6 +80,9 @@ private void OnGetVerbs(EntityUid uid, GuideHelpComponent component, GetVerbsEve private void OnInteract(EntityUid uid, GuideHelpComponent component, ActivateInWorldEvent args) { + if (!_timing.IsFirstTimePredicted) + return; + if (!component.OpenOnActivation || component.Guides.Count == 0 || _tags.HasTag(uid, GuideEmbedTag)) return; @@ -114,34 +139,26 @@ private void OnGuidebookControlsTestInteractHand(EntityUid uid, GuidebookControl _audioSystem.PlayGlobal(speech.SpeechSounds, Filter.Local(), false, speech.AudioParams); } - public void FakeClientActivateInWorld(EntityUid activated) { - var user = _playerManager.LocalPlayer!.ControlledEntity; - if (user is null) - return; - var activateMsg = new ActivateInWorldEvent(user.Value, activated); - RaiseLocalEvent(activated, activateMsg, true); + var activateMsg = new ActivateInWorldEvent(GetGuidebookUser(), activated); + RaiseLocalEvent(activated, activateMsg); } public void FakeClientAltActivateInWorld(EntityUid activated) { - var user = _playerManager.LocalPlayer!.ControlledEntity; - if (user is null) - return; // Get list of alt-interact verbs - var verbs = _verbSystem.GetLocalVerbs(activated, user.Value, typeof(AlternativeVerb)); + var verbs = _verbSystem.GetLocalVerbs(activated, GetGuidebookUser(), typeof(AlternativeVerb), force: true); if (!verbs.Any()) return; - _verbSystem.ExecuteVerb(verbs.First(), user.Value, activated); + _verbSystem.ExecuteVerb(verbs.First(), GetGuidebookUser(), activated); } public void FakeClientUse(EntityUid activated) { - var user = _playerManager.LocalPlayer!.ControlledEntity ?? EntityUid.Invalid; - var activateMsg = new InteractHandEvent(user, activated); - RaiseLocalEvent(activated, activateMsg, true); + var activateMsg = new InteractHandEvent(GetGuidebookUser(), activated); + RaiseLocalEvent(activated, activateMsg); } } diff --git a/Content.Client/Guidebook/Richtext/TextLinkTag.cs b/Content.Client/Guidebook/Richtext/TextLinkTag.cs new file mode 100644 index 0000000000..b1e8460bb8 --- /dev/null +++ b/Content.Client/Guidebook/Richtext/TextLinkTag.cs @@ -0,0 +1,70 @@ +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.RichText; +using Robust.Shared.Input; +using Robust.Shared.Utility; + +namespace Content.Client.Guidebook.RichText; + +[UsedImplicitly] +public sealed class TextLinkTag : IMarkupTag +{ + public string Name => "textlink"; + + public Control? Control; + + /// + public bool TryGetControl(MarkupNode node, [NotNullWhen(true)] out Control? control) + { + if (!node.Value.TryGetString(out var text) + || !node.Attributes.TryGetValue("link", out var linkParameter) + || !linkParameter.TryGetString(out var link)) + { + control = null; + return false; + } + + var label = new Label(); + label.Text = text; + + label.MouseFilter = Control.MouseFilterMode.Stop; + label.FontColorOverride = Color.CornflowerBlue; + label.DefaultCursorShape = Control.CursorShape.Hand; + + label.OnMouseEntered += _ => label.FontColorOverride = Color.LightSkyBlue; + label.OnMouseExited += _ => label.FontColorOverride = Color.CornflowerBlue; + label.OnKeyBindDown += args => OnKeybindDown(args, link); + + control = label; + Control = label; + return true; + } + + private void OnKeybindDown(GUIBoundKeyEventArgs args, string link) + { + if (args.Function != EngineKeyFunctions.UIClick) + return; + + if (Control == null) + return; + + var current = Control; + while (current != null) + { + current = current.Parent; + + if (current is not ILinkClickHandler handler) + continue; + handler.HandleClick(link); + return; + } + Logger.Warning($"Warning! No valid ILinkClickHandler found."); + } +} + +public interface ILinkClickHandler +{ + public void HandleClick(string link); +} diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index c7d45ec085..b98b907cd0 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -18,6 +18,7 @@ using Content.Shared.Administration.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 @@ public static void Register() IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Client/MainMenu/UI/MainMenuControl.xaml.cs b/Content.Client/MainMenu/UI/MainMenuControl.xaml.cs index 5216547b92..e9e90288c6 100644 --- a/Content.Client/MainMenu/UI/MainMenuControl.xaml.cs +++ b/Content.Client/MainMenu/UI/MainMenuControl.xaml.cs @@ -1,13 +1,10 @@ -using Content.Client.Changelog; -using Content.Client.Parallax; -using Robust.Client.AutoGenerated; +using Robust.Client.AutoGenerated; using Robust.Client.ResourceManagement; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; using Robust.Shared; using Robust.Shared.Configuration; -using Robust.Shared.Localization; namespace Content.Client.MainMenu.UI { diff --git a/Content.Client/PDA/PDAMenu.xaml.cs b/Content.Client/PDA/PDAMenu.xaml.cs index 4b80795cc1..a639c9d184 100644 --- a/Content.Client/PDA/PDAMenu.xaml.cs +++ b/Content.Client/PDA/PDAMenu.xaml.cs @@ -37,11 +37,11 @@ public PDAMenu() ViewContainer.OnChildAdded += control => control.Visible = false; - HomeButton.IconTexture = new SpriteSpecifier.Texture(new ("/Textures/Interface/home.png")); - FlashLightToggleButton.IconTexture = new SpriteSpecifier.Texture(new ("/Textures/Interface/light.png")); - EjectPenButton.IconTexture = new SpriteSpecifier.Texture(new ("/Textures/Interface/pencil.png")); - EjectIdButton.IconTexture = new SpriteSpecifier.Texture(new ("/Textures/Interface/eject.png")); - ProgramCloseButton.IconTexture = new SpriteSpecifier.Texture(new ("/Textures/Interface/Nano/cross.svg.png")); + HomeButton.IconTexture = new SpriteSpecifier.Texture(new("/Textures/Interface/home.png")); + FlashLightToggleButton.IconTexture = new SpriteSpecifier.Texture(new("/Textures/Interface/light.png")); + EjectPenButton.IconTexture = new SpriteSpecifier.Texture(new("/Textures/Interface/pencil.png")); + EjectIdButton.IconTexture = new SpriteSpecifier.Texture(new("/Textures/Interface/eject.png")); + ProgramCloseButton.IconTexture = new SpriteSpecifier.Texture(new("/Textures/Interface/Nano/cross.svg.png")); HomeButton.OnPressed += _ => ToHomeScreen(); @@ -102,8 +102,8 @@ public void UpdateState(PDAUpdateState state) if (state.PDAOwnerInfo.IdOwner != null || state.PDAOwnerInfo.JobTitle != null) { IdInfoLabel.SetMarkup(Loc.GetString("comp-pda-ui", - ("owner",state.PDAOwnerInfo.IdOwner ?? Loc.GetString("comp-pda-ui-unknown")), - ("jobTitle",state.PDAOwnerInfo.JobTitle ?? Loc.GetString("comp-pda-ui-unassigned")))); + ("owner", state.PDAOwnerInfo.IdOwner ?? Loc.GetString("comp-pda-ui-unknown")), + ("jobTitle", state.PDAOwnerInfo.JobTitle ?? Loc.GetString("comp-pda-ui-unassigned")))); } else { @@ -111,7 +111,7 @@ public void UpdateState(PDAUpdateState state) } StationNameLabel.SetMarkup(Loc.GetString("comp-pda-ui-station", - ("station",state.StationName ?? Loc.GetString("comp-pda-ui-unknown")))); + ("station", state.StationName ?? Loc.GetString("comp-pda-ui-unknown")))); var stationTime = _gameTiming.CurTime.Subtract(_gameTicker.RoundStartTimeSpan); @@ -263,7 +263,7 @@ public void ChangeView(int view) _currentView = view; } - private BoxContainer CreateProgramListRow() + private static BoxContainer CreateProgramListRow() { return new BoxContainer() { diff --git a/Content.Client/PDA/PDAProgramItem.xaml.cs b/Content.Client/PDA/PDAProgramItem.xaml.cs index 5cc16da497..9348cc5c12 100644 --- a/Content.Client/PDA/PDAProgramItem.xaml.cs +++ b/Content.Client/PDA/PDAProgramItem.xaml.cs @@ -1,9 +1,7 @@ using Robust.Client.AutoGenerated; using Robust.Client.Graphics; -using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; -using Robust.Shared.Input; namespace Content.Client.PDA; diff --git a/Content.Client/PDA/PDASettingsButton.xaml.cs b/Content.Client/PDA/PDASettingsButton.xaml.cs index 0fb4ffb596..c62f214194 100644 --- a/Content.Client/PDA/PDASettingsButton.xaml.cs +++ b/Content.Client/PDA/PDASettingsButton.xaml.cs @@ -42,7 +42,7 @@ public Color? ForegroundColor { get => OptionName.FontColorOverride; - set + set { OptionName.FontColorOverride = value; OptionDescription.FontColorOverride = value; diff --git a/Content.Client/PDA/PDASystem.cs b/Content.Client/PDA/PDASystem.cs index 37834e0723..a5de98b9cf 100644 --- a/Content.Client/PDA/PDASystem.cs +++ b/Content.Client/PDA/PDASystem.cs @@ -18,10 +18,10 @@ private void OnAppearanceChange(EntityUid uid, PDAComponent component, ref Appea if (args.Sprite == null) return; - if (_appearance.TryGetData(uid, UnpoweredFlashlightVisuals.LightOn, out var isFlashlightOn, args.Component)) + if (Appearance.TryGetData(uid, UnpoweredFlashlightVisuals.LightOn, out var isFlashlightOn, args.Component)) args.Sprite.LayerSetVisible(PDAVisualLayers.Flashlight, isFlashlightOn); - if (_appearance.TryGetData(uid, PDAVisuals.IDCardInserted, out var isCardInserted, args.Component)) + if (Appearance.TryGetData(uid, PDAVisuals.IDCardInserted, out var isCardInserted, args.Component)) args.Sprite.LayerSetVisible(PDAVisualLayers.IDLight, isCardInserted); } @@ -29,7 +29,7 @@ protected override void OnComponentInit(EntityUid uid, PDAComponent component, C { base.OnComponentInit(uid, component, args); - if(!TryComp(uid, out var sprite)) + if (!TryComp(uid, out var sprite)) return; if (component.State != null) @@ -38,11 +38,11 @@ protected override void OnComponentInit(EntityUid uid, PDAComponent component, C sprite.LayerSetVisible(PDAVisualLayers.Flashlight, component.FlashlightOn); sprite.LayerSetVisible(PDAVisualLayers.IDLight, component.IdSlot.StartingItem != null); } -} -enum PDAVisualLayers : byte -{ - Base, - Flashlight, - IDLight + public enum PDAVisualLayers : byte + { + Base, + Flashlight, + IDLight + } } diff --git a/Content.Client/PDA/PDAWindow.xaml.cs b/Content.Client/PDA/PDAWindow.xaml.cs index 8b39d744cd..4a7ef89c64 100644 --- a/Content.Client/PDA/PDAWindow.xaml.cs +++ b/Content.Client/PDA/PDAWindow.xaml.cs @@ -22,7 +22,7 @@ public string? AccentHColor set { - AccentH.ModulateSelfOverride = Color. FromHex(value, Color.White); + AccentH.ModulateSelfOverride = Color.FromHex(value, Color.White); AccentH.Visible = value != null; } } @@ -33,7 +33,7 @@ public string? AccentVColor set { - AccentV.ModulateSelfOverride = Color. FromHex(value, Color.White); + AccentV.ModulateSelfOverride = Color.FromHex(value, Color.White); AccentV.Visible = value != null; } } diff --git a/Content.Client/PDA/Ringer/RingtoneMenu.xaml.cs b/Content.Client/PDA/Ringer/RingtoneMenu.xaml.cs index cb638efcd9..1363fa66d1 100644 --- a/Content.Client/PDA/Ringer/RingtoneMenu.xaml.cs +++ b/Content.Client/PDA/Ringer/RingtoneMenu.xaml.cs @@ -18,20 +18,38 @@ public RingtoneMenu() RingerNoteInputs = new[] { RingerNoteOneInput, RingerNoteTwoInput, RingerNoteThreeInput, RingerNoteFourInput }; - for (int i = 0; i < RingerNoteInputs.Length; i++) + for (var i = 0; i < RingerNoteInputs.Length; ++i) { var input = RingerNoteInputs[i]; - int index = i; - input.OnTextChanged += _ => //Prevents unauthorized characters from being entered into the LineEdit + var index = i; + var foo = () => // Prevents unauthorized characters from being entered into the LineEdit { input.Text = input.Text.ToUpper(); if (!IsNote(input.Text)) + { input.Text = PreviousNoteInputs[index]; + } else PreviousNoteInputs[index] = input.Text; - input.CursorPosition = input.Text.Length; //Resets caret position to the end of the typed input + input.RemoveStyleClass("Caution"); + }; + + input.OnFocusExit += _ => foo(); + input.OnTextEntered += _ => + { + foo(); + input.CursorPosition = input.Text.Length; // Resets caret position to the end of the typed input + }; + input.OnTextChanged += _ => + { + input.Text = input.Text.ToUpper(); + + if (!IsNote(input.Text)) + input.AddStyleClass("Caution"); + else + input.RemoveStyleClass("Caution"); }; } } diff --git a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorBoundUserInterface.cs b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorBoundUserInterface.cs index c41f67173d..734def3653 100644 --- a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorBoundUserInterface.cs +++ b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorBoundUserInterface.cs @@ -1,6 +1,5 @@ using Content.Shared.Singularity.Components; using Robust.Client.GameObjects; -using Robust.Shared.GameObjects; namespace Content.Client.ParticleAccelerator.UI { diff --git a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.cs b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.cs index 0b916c7600..0df9363cb8 100644 --- a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.cs +++ b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.cs @@ -19,10 +19,10 @@ public sealed class ParticleAcceleratorControlMenu : BaseWindow { private readonly ShaderInstance _greyScaleShader; - private readonly ParticleAcceleratorBoundUserInterface Owner; + private readonly ParticleAcceleratorBoundUserInterface _owner; private readonly Label _drawLabel; - private readonly NoiseGenerator _drawNoiseGenerator; + private readonly FastNoiseLite _drawNoiseGenerator; private readonly Button _onButton; private readonly Button _offButton; private readonly Button _scanButton; @@ -36,9 +36,9 @@ public sealed class ParticleAcceleratorControlMenu : BaseWindow private readonly PASegmentControl _fuelChamberTexture; private readonly PASegmentControl _controlBoxTexture; private readonly PASegmentControl _powerBoxTexture; - private readonly PASegmentControl _emitterCenterTexture; - private readonly PASegmentControl _emitterRightTexture; - private readonly PASegmentControl _emitterLeftTexture; + private readonly PASegmentControl _emitterForeTexture; + private readonly PASegmentControl _emitterPortTexture; + private readonly PASegmentControl _emitterStarboardTexture; private float _time; private int _lastDraw; @@ -47,14 +47,16 @@ public sealed class ParticleAcceleratorControlMenu : BaseWindow private bool _blockSpinBox; private bool _assembled; private bool _shouldContinueAnimating; + private int _maxStrength = 3; public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owner) { SetSize = (400, 320); _greyScaleShader = IoCManager.Resolve().Index("Greyscale").Instance(); - Owner = owner; - _drawNoiseGenerator = new NoiseGenerator(NoiseGenerator.NoiseType.Fbm); + _owner = owner; + _drawNoiseGenerator = new(); + _drawNoiseGenerator.SetFractalType(FastNoiseLite.FractalType.FBm); _drawNoiseGenerator.SetFrequency(0.5f); var resourceCache = IoCManager.Resolve(); @@ -98,7 +100,7 @@ public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owne MouseFilter = MouseFilterMode.Pass }); - _stateSpinBox = new SpinBox {Value = 0, IsValid = StrengthSpinBoxValid,}; + _stateSpinBox = new SpinBox { Value = 0, IsValid = StrengthSpinBoxValid }; _stateSpinBox.InitDefaultButtons(); _stateSpinBox.ValueChanged += PowerStateChanged; _stateSpinBox.LineEditDisabled = true; @@ -107,7 +109,7 @@ public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owne { ToggleMode = false, Text = Loc.GetString("particle-accelerator-control-menu-off-button"), - StyleClasses = {StyleBase.ButtonOpenRight}, + StyleClasses = { StyleBase.ButtonOpenRight }, }; _offButton.OnPressed += args => owner.SendEnableMessage(false); @@ -115,13 +117,13 @@ public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owne { ToggleMode = false, Text = Loc.GetString("particle-accelerator-control-menu-on-button"), - StyleClasses = {StyleBase.ButtonOpenLeft}, + StyleClasses = { StyleBase.ButtonOpenLeft }, }; _onButton.OnPressed += args => owner.SendEnableMessage(true); var closeButton = new TextureButton { - StyleClasses = {"windowCloseButton"}, + StyleClasses = { "windowCloseButton" }, HorizontalAlignment = HAlignment.Right, Margin = new Thickness(0, 0, 8, 0) }; @@ -130,7 +132,7 @@ public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owne var serviceManual = new Label { HorizontalAlignment = HAlignment.Center, - StyleClasses = {StyleBase.StyleClassLabelSubText}, + StyleClasses = { StyleBase.StyleClassLabelSubText }, Text = Loc.GetString("particle-accelerator-control-menu-service-manual-reference") }; _drawLabel = new Label(); @@ -227,7 +229,8 @@ public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owne Align = Label.AlignMode.Center }, serviceManual - } + }, + Visible = false, }), } }, @@ -267,9 +270,9 @@ public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owne new Control {MinSize = imgSize}, (_powerBoxTexture = Segment("power_box")), new Control {MinSize = imgSize}, - (_emitterLeftTexture = Segment("emitter_left")), - (_emitterCenterTexture = Segment("emitter_center")), - (_emitterRightTexture = Segment("emitter_right")), + (_emitterStarboardTexture = Segment("emitter_starboard")), + (_emitterForeTexture = Segment("emitter_fore")), + (_emitterPortTexture = Segment("emitter_port")), } } } @@ -312,7 +315,7 @@ public ParticleAcceleratorControlMenu(ParticleAcceleratorBoundUserInterface owne } }); - _scanButton.OnPressed += args => Owner.SendScanPartsMessage(); + _scanButton.OnPressed += args => _owner.SendScanPartsMessage(); _alarmControl.AnimationCompleted += s => { @@ -336,7 +339,7 @@ PASegmentControl Segment(string name) private bool StrengthSpinBoxValid(int n) { - return (n >= 0 && n <= 3 && !_blockSpinBox); + return n >= 0 && n <= _maxStrength && !_blockSpinBox; } private void PowerStateChanged(ValueChangedEventArgs e) @@ -356,16 +359,15 @@ private void PowerStateChanged(ValueChangedEventArgs e) case 3: newState = ParticleAcceleratorPowerState.Level2; break; - // They can't reach this level anyway and I just want to fix the bugginess for now. - //case 4: - // newState = ParticleAcceleratorPowerState.Level3; - // break; + case 4: + newState = ParticleAcceleratorPowerState.Level3; + break; default: return; } _stateSpinBox.SetButtonDisabled(true); - Owner.SendPowerStateMessage(newState); + _owner.SendPowerStateMessage(newState); } protected override DragMode GetDragModeFor(Vector2 relativeMousePos) @@ -402,14 +404,15 @@ private void UpdatePowerState(ParticleAcceleratorPowerState state, bool enabled, }); - _shouldContinueAnimating = false; - _alarmControl.StopAnimation("warningAnim"); - _alarmControl.Visible = false; - if (maxState == ParticleAcceleratorPowerState.Level3 && enabled && assembled) + _maxStrength = maxState == ParticleAcceleratorPowerState.Level3 ? 4 : 3; + if (_maxStrength > 3 && enabled && assembled) { _shouldContinueAnimating = true; - _alarmControl.PlayAnimation(_alarmControlAnimation, "warningAnim"); + if (!_alarmControl.Visible) + _alarmControl.PlayAnimation(_alarmControlAnimation, "warningAnim"); } + else + _shouldContinueAnimating = false; } private void UpdateUI(bool assembled, bool blocked, bool enabled, bool powerBlock) @@ -430,12 +433,12 @@ private void UpdateUI(bool assembled, bool blocked, bool enabled, bool powerBloc private void UpdatePreview(ParticleAcceleratorUIState updateMessage) { _endCapTexture.SetPowerState(updateMessage, updateMessage.EndCapExists); - _fuelChamberTexture.SetPowerState(updateMessage, updateMessage.FuelChamberExists); _controlBoxTexture.SetPowerState(updateMessage, true); + _fuelChamberTexture.SetPowerState(updateMessage, updateMessage.FuelChamberExists); _powerBoxTexture.SetPowerState(updateMessage, updateMessage.PowerBoxExists); - _emitterCenterTexture.SetPowerState(updateMessage, updateMessage.EmitterCenterExists); - _emitterLeftTexture.SetPowerState(updateMessage, updateMessage.EmitterLeftExists); - _emitterRightTexture.SetPowerState(updateMessage, updateMessage.EmitterRightExists); + _emitterStarboardTexture.SetPowerState(updateMessage, updateMessage.EmitterStarboardExists); + _emitterForeTexture.SetPowerState(updateMessage, updateMessage.EmitterForeExists); + _emitterPortTexture.SetPowerState(updateMessage, updateMessage.EmitterPortExists); } protected override void FrameUpdate(FrameEventArgs args) @@ -453,7 +456,7 @@ protected override void FrameUpdate(FrameEventArgs args) var watts = 0; if (_lastDraw != 0) { - var val = _drawNoiseGenerator.GetNoise(_time); + var val = _drawNoiseGenerator.GetNoise(_time, 0f); watts = (int) (_lastDraw + val * 5); } @@ -476,7 +479,7 @@ public PASegmentControl(ParticleAcceleratorControlMenu menu, IResourceCache cach _baseState = name; _rsi = cache.GetResource($"/Textures/Structures/Power/Generation/PA/{name}.rsi").RSI; - AddChild(_base = new TextureRect {Texture = _rsi[$"completed"].Frame0}); + AddChild(_base = new TextureRect { Texture = _rsi[$"completed"].Frame0 }); AddChild(_unlit = new TextureRect()); MinSize = _rsi.Size; } diff --git a/Content.Client/Replay/ContentReplayPlaybackManager.cs b/Content.Client/Replay/ContentReplayPlaybackManager.cs new file mode 100644 index 0000000000..0ecb98ec0d --- /dev/null +++ b/Content.Client/Replay/ContentReplayPlaybackManager.cs @@ -0,0 +1,143 @@ +using Content.Client.Administration.Managers; +using Content.Client.Launcher; +using Content.Client.MainMenu; +using Content.Client.Replay.UI.Loading; +using Content.Client.UserInterface.Systems.Chat; +using Content.Shared.Chat; +using Content.Shared.GameTicking; +using Content.Shared.Hands; +using Content.Shared.Instruments; +using Content.Shared.Popups; +using Content.Shared.Projectiles; +using Content.Shared.Weapons.Melee; +using Content.Shared.Weapons.Melee.Events; +using Content.Shared.Weapons.Ranged.Events; +using Content.Shared.Weapons.Ranged.Systems; +using Robust.Client; +using Robust.Client.Console; +using Robust.Client.GameObjects; +using Robust.Client.Replays.Loading; +using Robust.Client.Replays.Playback; +using Robust.Client.State; +using Robust.Client.Timing; +using Robust.Client.UserInterface; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; + +namespace Content.Client.Replay; + +public sealed class ContentReplayPlaybackManager +{ + [Dependency] private readonly IStateManager _stateMan = default!; + [Dependency] private readonly IClientGameTiming _timing = default!; + [Dependency] private readonly IReplayLoadManager _loadMan = default!; + [Dependency] private readonly IGameController _controller = default!; + [Dependency] private readonly IClientEntityManager _entMan = default!; + [Dependency] private readonly IUserInterfaceManager _uiMan = default!; + [Dependency] private readonly IReplayPlaybackManager _playback = default!; + [Dependency] private readonly IClientConGroupController _conGrp = default!; + [Dependency] private readonly IClientAdminManager _adminMan = default!; + + /// + /// UI state to return to when stopping a replay or loading fails. + /// + public Type? DefaultState; + + private bool _initialized; + + public void Initialize() + { + if (_initialized) + return; + + _initialized = true; + _playback.HandleReplayMessage += OnHandleReplayMessage; + _playback.ReplayPlaybackStopped += OnReplayPlaybackStopped; + _playback.ReplayPlaybackStarted += OnReplayPlaybackStarted; + _playback.ReplayCheckpointReset += OnCheckpointReset; + _loadMan.LoadOverride += LoadOverride; + } + + private void LoadOverride(IWritableDirProvider dir, ResPath resPath) + { + var screen = _stateMan.RequestStateChange>(); + screen.Job = new ContentLoadReplayJob(1/60f, dir, resPath, _loadMan, screen); + screen.OnJobFinished += (_, e) => OnFinishedLoading(e); + } + + private void OnFinishedLoading(Exception? exception) + { + if (exception != null) + { + ReturnToDefaultState(); + _uiMan.Popup(Loc.GetString("replay-loading-failed", ("reason", exception))); + } + } + + public void ReturnToDefaultState() + { + if (DefaultState != null) + _stateMan.RequestStateChange(DefaultState); + else if (_controller.LaunchState.FromLauncher) + _stateMan.RequestStateChange().SetDisconnected(); + else + _stateMan.RequestStateChange(); + } + + private void OnCheckpointReset() + { + // This function removes future chat messages when rewinding time. + + // TODO REPLAYS add chat messages when jumping forward in time. + // Need to allow content to add data to checkpoint states. + + _uiMan.GetUIController().History.RemoveAll(x => x.Item1 > _timing.CurTick); + _uiMan.GetUIController().Repopulate(); + } + + private bool OnHandleReplayMessage(object message, bool skipEffects) + { + switch (message) + { + case ChatMessage chat: + // Just pass on the chat message to the UI controller, but skip speech-bubbles if we are fast-forwarding. + _uiMan.GetUIController().ProcessChatMessage(chat, speechBubble: !skipEffects); + return true; + // TODO REPLAYS figure out a cleaner way of doing this. This sucks. + // Next: we want to avoid spamming animations, sounds, and pop-ups while scrubbing or rewinding time + // (e.g., to rewind 1 tick, we really rewind ~60 and then fast forward 59). Currently, this is + // effectively an EntityEvent blacklist. But this is kinda shit and should be done differently somehow. + // The unifying aspect of these events is that they trigger pop-ups, UI changes, spawn client-side + // entities or start animations. + case RoundEndMessageEvent: + case PopupEvent: + case AudioMessage: + case PickupAnimationEvent: + case MeleeLungeEvent: + case SharedGunSystem.HitscanEvent: + case ImpactEffectEvent: + case MuzzleFlashEvent: + case DamageEffectEvent: + case InstrumentStartMidiEvent: + case InstrumentMidiEventEvent: + case InstrumentStopMidiEvent: + if (!skipEffects) + _entMan.DispatchReceivedNetworkMsg((EntityEventArgs)message); + return true; + } + + return false; + } + + + private void OnReplayPlaybackStarted() + { + _conGrp.Implementation = new ReplayConGroup(); + } + + private void OnReplayPlaybackStopped() + { + _conGrp.Implementation = (IClientConGroupImplementation)_adminMan; + ReturnToDefaultState(); + } +} diff --git a/Content.Client/Replay/LoadReplayJob.cs b/Content.Client/Replay/LoadReplayJob.cs new file mode 100644 index 0000000000..1064008609 --- /dev/null +++ b/Content.Client/Replay/LoadReplayJob.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Content.Client.Replay.UI.Loading; +using Robust.Client.Replays.Loading; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; + +namespace Content.Client.Replay; + +public sealed class ContentLoadReplayJob : LoadReplayJob +{ + private readonly LoadingScreen _screen; + + public ContentLoadReplayJob( + float maxTime, + IWritableDirProvider dir, + ResPath path, + IReplayLoadManager loadMan, + LoadingScreen screen) + : base(maxTime, dir, path, loadMan) + { + _screen = screen; + } + + protected override async Task Yield(float value, float maxValue, LoadingState state, bool force) + { + var header = Loc.GetString("replay-loading", ("cur", (int)state + 1), ("total", 5)); + var subText = Loc.GetString(state switch + { + LoadingState.ReadingFiles => "replay-loading-reading", + LoadingState.ProcessingFiles => "replay-loading-processing", + LoadingState.Spawning => "replay-loading-spawning", + LoadingState.Initializing => "replay-loading-initializing", + _ => "replay-loading-starting", + }); + _screen.UpdateProgress(value, maxValue, header, subText); + + await base.Yield(value, maxValue, state, force); + } +} diff --git a/Content.Client/Replay/ReplayConGroup.cs b/Content.Client/Replay/ReplayConGroup.cs new file mode 100644 index 0000000000..8e85632683 --- /dev/null +++ b/Content.Client/Replay/ReplayConGroup.cs @@ -0,0 +1,13 @@ +using Robust.Client.Console; + +namespace Content.Client.Replay; + +public sealed class ReplayConGroup : IClientConGroupImplementation +{ + public event Action? ConGroupUpdated; + public bool CanAdminMenu() => true; + public bool CanAdminPlace() => true; + public bool CanCommand(string cmdName) => true; + public bool CanScript() => true; + public bool CanViewVar() => true; +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorComponent.cs b/Content.Client/Replay/Spectator/ReplaySpectatorComponent.cs new file mode 100644 index 0000000000..509240a386 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorComponent.cs @@ -0,0 +1,9 @@ +namespace Content.Client.Replay.Spectator; + +/// +/// This component indicates that this entity currently has a replay spectator/observer attached to it. +/// +[RegisterComponent] +public sealed class ReplaySpectatorComponent : Component +{ +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Blockers.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Blockers.cs new file mode 100644 index 0000000000..86d113defb --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Blockers.cs @@ -0,0 +1,44 @@ +using Content.Shared.Hands; +using Content.Shared.Interaction.Events; +using Content.Shared.Inventory.Events; +using Content.Shared.Item; +using Content.Shared.Movement.Events; +using Content.Shared.Physics.Pull; +using Content.Shared.Throwing; + +namespace Content.Client.Replay.Spectator; + +public sealed partial class ReplaySpectatorSystem +{ + private void InitializeBlockers() + { + // Block most interactions to avoid mispredicts + // This **shouldn't** be required, but just in case. + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnAttempt); + SubscribeLocalEvent(OnUpdateCanMove); + SubscribeLocalEvent(OnUpdateCanMove); + SubscribeLocalEvent(OnPullAttempt); + } + + private void OnAttempt(EntityUid uid, ReplaySpectatorComponent component, CancellableEntityEventArgs args) + { + args.Cancel(); + } + + private void OnUpdateCanMove(EntityUid uid, ReplaySpectatorComponent component, CancellableEntityEventArgs args) + { + args.Cancel(); + } + + private void OnPullAttempt(EntityUid uid, ReplaySpectatorComponent component, PullAttemptEvent args) + { + args.Cancelled = true; + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs new file mode 100644 index 0000000000..52cf3d9c3b --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Movement.cs @@ -0,0 +1,123 @@ +using Content.Shared.Movement.Components; +using Robust.Shared.Input; +using Robust.Shared.Input.Binding; +using Robust.Shared.Map; +using Robust.Shared.Players; + +namespace Content.Client.Replay.Spectator; + +// Partial class handles movement logic for observers. +public sealed partial class ReplaySpectatorSystem +{ + public DirectionFlag Direction; + + /// + /// Fallback speed if the observer ghost has no . + /// + public const float DefaultSpeed = 12; + + private void InitializeMovement() + { + var moveUpCmdHandler = new MoverHandler(this, DirectionFlag.North); + var moveLeftCmdHandler = new MoverHandler(this, DirectionFlag.West); + var moveRightCmdHandler = new MoverHandler(this, DirectionFlag.East); + var moveDownCmdHandler = new MoverHandler(this, DirectionFlag.South); + + CommandBinds.Builder + .Bind(EngineKeyFunctions.MoveUp, moveUpCmdHandler) + .Bind(EngineKeyFunctions.MoveLeft, moveLeftCmdHandler) + .Bind(EngineKeyFunctions.MoveRight, moveRightCmdHandler) + .Bind(EngineKeyFunctions.MoveDown, moveDownCmdHandler) + .Register(); + } + + private void ShutdownMovement() + { + CommandBinds.Unregister(); + } + + // Normal mover code works via physics. Replays don't do prediction/physics. You can fudge it by relying on the + // fact that only local-player physics is currently predicted, but instead I've just added crude mover logic here. + // This just runs on frame updates, no acceleration or friction here. + public override void FrameUpdate(float frameTime) + { + if (_replayPlayback.Replay == null) + return; + + if (_player.LocalPlayer?.ControlledEntity is not { } player) + return; + + if (Direction == DirectionFlag.None) + { + if (TryComp(player, out InputMoverComponent? cmp)) + _mover.LerpRotation(cmp, frameTime); + return; + } + + if (!player.IsClientSide() || !HasComp(player)) + { + // Player is trying to move -> behave like the ghost-on-move component. + SpawnSpectatorGhost(new EntityCoordinates(player, default), true); + return; + } + + if (!TryComp(player, out InputMoverComponent? mover)) + return; + + _mover.LerpRotation(mover, frameTime); + + var effectiveDir = Direction; + if ((Direction & DirectionFlag.North) != 0) + effectiveDir &= ~DirectionFlag.South; + + if ((Direction & DirectionFlag.East) != 0) + effectiveDir &= ~DirectionFlag.West; + + var query = GetEntityQuery(); + var xform = query.GetComponent(player); + var pos = _transform.GetWorldPosition(xform, query); + + if (!xform.ParentUid.IsValid()) + { + // Were they sitting on a grid as it was getting deleted? + SetSpectatorPosition(default); + return; + } + + // A poor mans grid-traversal system. Should also interrupt ghost-following. + _transform.SetGridId(player, xform, null); + _transform.AttachToGridOrMap(player); + + var parentRotation = _mover.GetParentGridAngle(mover, query); + var localVec = effectiveDir.AsDir().ToAngle().ToWorldVec(); + var worldVec = parentRotation.RotateVec(localVec); + var speed = CompOrNull(player)?.BaseSprintSpeed ?? DefaultSpeed; + var delta = worldVec * frameTime * speed; + _transform.SetWorldPositionRotation(xform, pos + delta, delta.ToWorldAngle(), query); + } + + private sealed class MoverHandler : InputCmdHandler + { + private readonly ReplaySpectatorSystem _sys; + private readonly DirectionFlag _dir; + + public MoverHandler(ReplaySpectatorSystem sys, DirectionFlag dir) + { + _sys = sys; + _dir = dir; + } + + public override bool HandleCmdMessage(ICommonSession? session, InputCmdMessage message) + { + if (message is not FullInputCmdMessage full) + return false; + + if (full.State == BoundKeyState.Down) + _sys.Direction |= _dir; + else + _sys.Direction &= ~_dir; + + return true; + } + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Position.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Position.cs new file mode 100644 index 0000000000..3b17191c16 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Position.cs @@ -0,0 +1,132 @@ +using System.Linq; +using Content.Shared.Movement.Components; +using Robust.Client.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; + +namespace Content.Client.Replay.Spectator; + +// This partial class contains functions for getting and setting the spectator's position data, so that +// a consistent view/camera can be maintained when jumping around in time. +public sealed partial class ReplaySpectatorSystem +{ + /// + /// Simple struct containing position & rotation data for maintaining a persistent view when jumping around in time. + /// + public struct SpectatorPosition + { + // TODO REPLAYS handle ghost-following. + public EntityUid Entity; + public (EntityCoordinates Coords, Angle Rot)? Local; + public (EntityCoordinates Coords, Angle Rot)? World; + public (EntityUid? Ent, Angle Rot)? Eye; + } + + public SpectatorPosition GetSpectatorPosition() + { + var obs = new SpectatorPosition(); + if (_player.LocalPlayer?.ControlledEntity is { } player && TryComp(player, out TransformComponent? xform) && xform.MapUid != null) + { + obs.Local = (xform.Coordinates, xform.LocalRotation); + obs.World = (new(xform.MapUid.Value, xform.WorldPosition), xform.WorldRotation); + + if (TryComp(player, out InputMoverComponent? mover)) + obs.Eye = (mover.RelativeEntity, mover.TargetRelativeRotation); + + obs.Entity = player; + } + + return obs; + } + + private void OnBeforeSetTick() + { + _oldPosition = GetSpectatorPosition(); + } + + private void OnAfterSetTick() + { + if (_oldPosition != null) + SetSpectatorPosition(_oldPosition.Value); + _oldPosition = null; + } + + public void SetSpectatorPosition(SpectatorPosition spectatorPosition) + { + if (Exists(spectatorPosition.Entity) && Transform(spectatorPosition.Entity).MapID != MapId.Nullspace) + { + _player.LocalPlayer!.AttachEntity(spectatorPosition.Entity, EntityManager, _client); + return; + } + + if (spectatorPosition.Local != null && spectatorPosition.Local.Value.Coords.IsValid(EntityManager)) + { + var newXform = SpawnSpectatorGhost(spectatorPosition.Local.Value.Coords, false); + newXform.LocalRotation = spectatorPosition.Local.Value.Rot; + } + else if (spectatorPosition.World != null && spectatorPosition.World.Value.Coords.IsValid(EntityManager)) + { + var newXform = SpawnSpectatorGhost(spectatorPosition.World.Value.Coords, true); + newXform.LocalRotation = spectatorPosition.World.Value.Rot; + } + else if (TryFindFallbackSpawn(out var coords)) + { + var newXform = SpawnSpectatorGhost(coords, true); + newXform.LocalRotation = 0; + } + else + { + Logger.Error("Failed to find a suitable observer spawn point"); + return; + } + + if (spectatorPosition.Eye != null && TryComp(_player.LocalPlayer?.ControlledEntity, out InputMoverComponent? newMover)) + { + newMover.RelativeEntity = spectatorPosition.Eye.Value.Ent; + newMover.TargetRelativeRotation = newMover.RelativeRotation = spectatorPosition.Eye.Value.Rot; + } + } + + private bool TryFindFallbackSpawn(out EntityCoordinates coords) + { + var uid = EntityQuery() + .OrderByDescending(x => x.LocalAABB.Size.LengthSquared) + .FirstOrDefault()?.Owner; + coords = new EntityCoordinates(uid ?? default, default); + return uid != null; + } + + private void OnTerminating(EntityUid uid, ReplaySpectatorComponent component, ref EntityTerminatingEvent args) + { + if (uid != _player.LocalPlayer?.ControlledEntity) + return; + + var xform = Transform(uid); + if (xform.MapUid == null || Terminating(xform.MapUid.Value)) + return; + + SpawnSpectatorGhost(new EntityCoordinates(xform.MapUid.Value, default), true); + } + + private void OnParentChanged(EntityUid uid, ReplaySpectatorComponent component, ref EntParentChangedMessage args) + { + if (uid != _player.LocalPlayer?.ControlledEntity) + return; + + if (args.Transform.MapUid != null || args.OldMapId == MapId.Nullspace) + return; + + // The entity being spectated from was moved to null-space. + // This was probably because they were spectating some entity in a client-side replay that left PVS range. + // Simple respawn the ghost. + SetSpectatorPosition(default); + } + + private void OnDetached(EntityUid uid, ReplaySpectatorComponent component, PlayerDetachedEvent args) + { + if (uid.IsClientSide()) + QueueDel(uid); + else + RemCompDeferred(uid, component); + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Spectate.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Spectate.cs new file mode 100644 index 0000000000..0d4775a498 --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.Spectate.cs @@ -0,0 +1,124 @@ +using Content.Client.Replay.UI; +using Content.Shared.Verbs; +using Robust.Shared.Console; +using Robust.Shared.Map; +using Robust.Shared.Utility; + +namespace Content.Client.Replay.Spectator; + +// This partial class has methods for spawning a spectator ghost and "possessing" entitites. +public sealed partial class ReplaySpectatorSystem +{ + private void OnGetAlternativeVerbs(GetVerbsEvent ev) + { + if (_replayPlayback.Replay == null) + return; + + var verb = new AlternativeVerb + { + Priority = 100, + Act = () => + { + SpectateEntity(ev.Target); + }, + + Text = Loc.GetString("replay-verb-spectate"), + Icon = new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/VerbIcons/vv.svg.192dpi.png")) + }; + + ev.Verbs.Add(verb); + } + + public void SpectateEntity(EntityUid target) + { + if (_player.LocalPlayer == null) + return; + + var old = _player.LocalPlayer.ControlledEntity; + + if (old == target) + { + // un-visit + SpawnSpectatorGhost(Transform(target).Coordinates, true); + return; + } + + _player.LocalPlayer.AttachEntity(target, EntityManager, _client); + EnsureComp(target); + + _stateMan.RequestStateChange(); + if (old == null) + return; + + if (old.Value.IsClientSide()) + Del(old.Value); + else + RemComp(old.Value); + } + + public TransformComponent SpawnSpectatorGhost(EntityCoordinates coords, bool gridAttach) + { + if (_player.LocalPlayer == null) + throw new InvalidOperationException(); + + var old = _player.LocalPlayer.ControlledEntity; + + var ent = Spawn("MobObserver", coords); + _eye.SetMaxZoom(ent, Vector2.One * 5); + EnsureComp(ent); + + var xform = Transform(ent); + + if (gridAttach) + _transform.AttachToGridOrMap(ent); + + _player.LocalPlayer.AttachEntity(ent, EntityManager, _client); + + if (old != null) + { + if (old.Value.IsClientSide()) + QueueDel(old.Value); + else + RemComp(old.Value); + } + + _stateMan.RequestStateChange(); + + return xform; + } + + private void SpectateCommand(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length == 0) + { + if (_player.LocalPlayer?.ControlledEntity is { } current) + SpawnSpectatorGhost(new EntityCoordinates(current, default), true); + else + SpawnSpectatorGhost(default, true); + return; + } + + if (!EntityUid.TryParse(args[0], out var uid)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-uid", ("arg", args[0]))); + return; + } + + if (!Exists(uid)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-entity-exist", ("arg", args[0]))); + return; + } + + SpectateEntity(uid); + } + + private CompletionResult SpectateCompletions(IConsoleShell shell, string[] args) + { + if (args.Length != 1) + return CompletionResult.Empty; + + return CompletionResult.FromHintOptions(CompletionHelper.EntityUids(args[0], + EntityManager), Loc.GetString("cmd-replay-spectate-hint")); + } +} diff --git a/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs new file mode 100644 index 0000000000..add786544e --- /dev/null +++ b/Content.Client/Replay/Spectator/ReplaySpectatorSystem.cs @@ -0,0 +1,77 @@ +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; + +namespace Content.Client.Replay.Spectator; + +/// +/// This system handles spawning replay observer ghosts and maintaining their positions when traveling through time. +/// It also blocks most normal interactions, just in case. +/// +/// +/// E.g., if an observer is on a grid, and then jumps forward or backward in time to a point where the grid does not +/// exist, where should the observer go? This attempts to maintain their position and eye rotation or just re-spawns +/// them as needed. +/// +public sealed partial class ReplaySpectatorSystem : EntitySystem +{ + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IConsoleHost _conHost = default!; + [Dependency] private readonly IStateManager _stateMan = default!; + [Dependency] private readonly TransformSystem _transform = default!; + [Dependency] private readonly SharedMoverController _mover = default!; + [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly SharedContentEyeSystem _eye = default!; + [Dependency] private readonly IReplayPlaybackManager _replayPlayback = default!; + + private SpectatorPosition? _oldPosition; + public const string SpectateCmd = "replay_spectate"; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent>(OnGetAlternativeVerbs); + SubscribeLocalEvent(OnTerminating); + SubscribeLocalEvent(OnDetached); + SubscribeLocalEvent(OnParentChanged); + + InitializeBlockers(); + + _replayPlayback.BeforeSetTick += OnBeforeSetTick; + _replayPlayback.AfterSetTick += OnAfterSetTick; + _replayPlayback.ReplayPlaybackStarted += OnPlaybackStarted; + _replayPlayback.ReplayPlaybackStopped += OnPlaybackStopped; + } + + public override void Shutdown() + { + base.Shutdown(); + _replayPlayback.BeforeSetTick -= OnBeforeSetTick; + _replayPlayback.AfterSetTick -= OnAfterSetTick; + _replayPlayback.ReplayPlaybackStarted -= OnPlaybackStarted; + _replayPlayback.ReplayPlaybackStopped -= OnPlaybackStopped; + } + + private void OnPlaybackStarted() + { + InitializeMovement(); + SetSpectatorPosition(default); + _conHost.RegisterCommand(SpectateCmd, + Loc.GetString("cmd-replay-spectate-desc"), + Loc.GetString("cmd-replay-spectate-help"), + SpectateCommand, + SpectateCompletions); + } + + private void OnPlaybackStopped() + { + ShutdownMovement(); + _conHost.UnregisterCommand(SpectateCmd); + } +} diff --git a/Content.Client/Replay/UI/Loading/LoadingScreen.cs b/Content.Client/Replay/UI/Loading/LoadingScreen.cs new file mode 100644 index 0000000000..f3f75a2950 --- /dev/null +++ b/Content.Client/Replay/UI/Loading/LoadingScreen.cs @@ -0,0 +1,51 @@ +using Robust.Client.ResourceManagement; +using Robust.Client.State; +using Robust.Client.UserInterface; +using Robust.Shared.CPUJob.JobQueues; +using Robust.Shared.Timing; + +namespace Content.Client.Replay.UI.Loading; + +[Virtual] +public class LoadingScreen : State +{ + [Dependency] private readonly IResourceCache _resourceCache = default!; + [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; + + public event Action? OnJobFinished; + private LoadingScreenControl _screen = default!; + public Job? Job; + + public override void FrameUpdate(FrameEventArgs e) + { + base.FrameUpdate(e); + if (Job == null) + return; + + Job.Run(); + if (Job.Status != JobStatus.Finished) + return; + + OnJobFinished?.Invoke(Job.Result, Job.Exception); + Job = null; + } + + protected override void Startup() + { + _screen = new(_resourceCache); + _userInterfaceManager.StateRoot.AddChild(_screen); + } + + protected override void Shutdown() + { + _screen.Dispose(); + } + + public void UpdateProgress(float value, float maxValue, string header, string subtext = "") + { + _screen.Bar.Value = value; + _screen.Bar.MaxValue = maxValue; + _screen.Header.Text = header; + _screen.Subtext.Text = subtext; + } +} diff --git a/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml new file mode 100644 index 0000000000..58f353a5ff --- /dev/null +++ b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml.cs b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml.cs new file mode 100644 index 0000000000..bbb5111438 --- /dev/null +++ b/Content.Client/Replay/UI/Loading/LoadingScreenControl.xaml.cs @@ -0,0 +1,39 @@ +using Content.Client.Resources; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Utility; + +namespace Content.Client.Replay.UI.Loading; + +[GenerateTypedNameReferences] +public sealed partial class LoadingScreenControl : Control +{ + public static SpriteSpecifier Sprite = new SpriteSpecifier.Rsi(new ("/Textures/Mobs/Silicon/Bots/mommi.rsi"), "wiggle"); + + public LoadingScreenControl(IResourceCache resCache) + { + RobustXamlLoader.Load(this); + + LayoutContainer.SetAnchorPreset(this, LayoutContainer.LayoutPreset.Wide); + Header.FontOverride = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 24); + Subtext.FontOverride = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 12); + + SpriteLeft.SetFromSpriteSpecifier(Sprite); + SpriteRight.SetFromSpriteSpecifier(Sprite); + SpriteLeft.HorizontalAlignment = HAlignment.Stretch; + SpriteLeft.VerticalAlignment = VAlignment.Stretch; + SpriteLeft.DisplayRect.Stretch = TextureRect.StretchMode.KeepAspectCentered; + SpriteRight.DisplayRect.Stretch = TextureRect.StretchMode.KeepAspectCentered; + + Background.PanelOverride = new StyleBoxFlat() + { + BackgroundColor = Color.FromHex("#303033"), + BorderColor = Color.FromHex("#5a5a5a"), + BorderThickness = new Thickness(4) + }; + } +} diff --git a/Content.Client/Replay/UI/ReplayGhostState.cs b/Content.Client/Replay/UI/ReplayGhostState.cs new file mode 100644 index 0000000000..284e1198a3 --- /dev/null +++ b/Content.Client/Replay/UI/ReplayGhostState.cs @@ -0,0 +1,40 @@ +using Content.Client.UserInterface.Systems.Actions.Widgets; +using Content.Client.UserInterface.Systems.Alerts.Widgets; +using Content.Client.UserInterface.Systems.Ghost.Widgets; +using Content.Client.UserInterface.Systems.Hotbar.Widgets; + +namespace Content.Client.Replay.UI; + +/// +/// Gameplay state when moving around a replay as a ghost. +/// +public sealed class ReplayGhostState : ReplaySpectateEntityState +{ + protected override void Startup() + { + base.Startup(); + + var screen = UserInterfaceManager.ActiveScreen; + if (screen == null) + return; + + screen.ShowWidget(false); + screen.ShowWidget(false); + screen.ShowWidget(false); + screen.ShowWidget(false); + } + + protected override void Shutdown() + { + var screen = UserInterfaceManager.ActiveScreen; + if (screen != null) + { + screen.ShowWidget(true); + screen.ShowWidget(true); + screen.ShowWidget(true); + screen.ShowWidget(true); + } + + base.Shutdown(); + } +} diff --git a/Content.Client/Replay/UI/ReplaySpectateEntityState.cs b/Content.Client/Replay/UI/ReplaySpectateEntityState.cs new file mode 100644 index 0000000000..f36c366dae --- /dev/null +++ b/Content.Client/Replay/UI/ReplaySpectateEntityState.cs @@ -0,0 +1,48 @@ +using Content.Client.Gameplay; +using Content.Client.UserInterface.Systems.Chat; +using Content.Client.UserInterface.Systems.MenuBar.Widgets; +using Robust.Client.Replays.UI; +using static Robust.Client.UserInterface.Controls.LayoutContainer; + +namespace Content.Client.Replay.UI; + +/// +/// Gameplay state when observing/spectating an entity during a replay. +/// +[Virtual] +public class ReplaySpectateEntityState : GameplayState +{ + protected override void Startup() + { + base.Startup(); + + var screen = UserInterfaceManager.ActiveScreen; + if (screen == null) + return; + + screen.ShowWidget(false); + SetAnchorAndMarginPreset(screen.GetOrAddWidget(), LayoutPreset.TopLeft, margin: 10); + + foreach (var chatbox in UserInterfaceManager.GetUIController().Chats) + { + chatbox.ChatInput.Visible = false; + } + } + + protected override void Shutdown() + { + var screen = UserInterfaceManager.ActiveScreen; + if (screen != null) + { + screen.RemoveWidget(); + screen.ShowWidget(true); + } + + foreach (var chatbox in UserInterfaceManager.GetUIController().Chats) + { + chatbox.ChatInput.Visible = true; + } + + base.Shutdown(); + } +} diff --git a/Content.Client/RoundEnd/RoundEndSummaryWindow.cs b/Content.Client/RoundEnd/RoundEndSummaryWindow.cs index c6d989c0a8..8f83689e80 100644 --- a/Content.Client/RoundEnd/RoundEndSummaryWindow.cs +++ b/Content.Client/RoundEnd/RoundEndSummaryWindow.cs @@ -12,6 +12,7 @@ namespace Content.Client.RoundEnd public sealed class RoundEndSummaryWindow : DefaultWindow { private readonly IEntityManager _entityManager; + public int RoundId; public RoundEndSummaryWindow(string gm, string roundEnd, TimeSpan roundTimeSpan, int roundId, RoundEndMessageEvent.RoundEndPlayerInfo[] info, IEntityManager entityManager) @@ -28,6 +29,7 @@ public RoundEndSummaryWindow(string gm, string roundEnd, TimeSpan roundTimeSpan, // "clown slipped the crew x times.", "x shots were fired this round.", etc. // Also good for serious info. + RoundId = roundId; var roundEndTabs = new TabContainer(); roundEndTabs.AddChild(MakeRoundEndSummaryTab(gm, roundEnd, roundTimeSpan, roundId)); roundEndTabs.AddChild(MakePlayerManifestoTab(info)); diff --git a/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs b/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs index 1df5864926..658465f245 100644 --- a/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs +++ b/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Content.Client.Resources; using Robust.Client.AutoGenerated; using Robust.Client.Graphics; @@ -196,13 +197,38 @@ public void RecursiveSetExpanded(TreeItem item, bool value, int depth) if (depth == 0) return; depth--; - + foreach (var child in item.Body.Children) { RecursiveSetExpanded((TreeItem) child, value, depth); } } + public bool TryGetIndexFromMetadata(object metadata, [NotNullWhen(true)] out int? index) + { + index = null; + foreach (var item in Items) + { + if (item.Metadata?.Equals(metadata) ?? false) + { + index = item.Index; + break; + } + } + return index != null; + } + + public void ExpandParentEntries(int index) + { + Control? current = Items[index]; + while (current != null) + { + if (current is TreeItem item) + item.SetExpanded(true); + current = current.Parent; + } + } + public void Clear() { foreach (var item in Items) diff --git a/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs b/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs index 472ad49a3f..d325009912 100644 --- a/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs +++ b/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs @@ -108,18 +108,6 @@ private void OnScreenUnload() public void OnStateEntered(GameplayState state) { - DebugTools.Assert(_window == null); - - _window = UIManager.CreateWindow(); - LayoutContainer.SetAnchorPreset(_window, LayoutContainer.LayoutPreset.CenterTop); - - _window.OnOpen += OnWindowOpened; - _window.OnClose += OnWindowClosed; - _window.ClearButton.OnPressed += OnClearPressed; - _window.SearchBar.OnTextChanged += OnSearchChanged; - _window.FilterButton.OnItemSelected += OnFilterSelected; - - if (_actionsSystem != null) { _actionsSystem.ActionAdded += OnActionAdded; @@ -328,18 +316,6 @@ public void OnStateExited(GameplayState state) _actionsSystem.ActionsUpdated -= OnActionsUpdated; } - if (_window != null) - { - _window.OnOpen -= OnWindowOpened; - _window.OnClose -= OnWindowClosed; - _window.ClearButton.OnPressed -= OnClearPressed; - _window.SearchBar.OnTextChanged -= OnSearchChanged; - _window.FilterButton.OnItemSelected -= OnFilterSelected; - - _window.Dispose(); - _window = null; - } - CommandBinds.Unregister(); } @@ -779,10 +755,32 @@ private void UnloadGui() ActionsBar.PageButtons.LeftArrow.OnPressed -= OnLeftArrowPressed; ActionsBar.PageButtons.RightArrow.OnPressed -= OnRightArrowPressed; + + if (_window != null) + { + _window.OnOpen -= OnWindowOpened; + _window.OnClose -= OnWindowClosed; + _window.ClearButton.OnPressed -= OnClearPressed; + _window.SearchBar.OnTextChanged -= OnSearchChanged; + _window.FilterButton.OnItemSelected -= OnFilterSelected; + + _window.Dispose(); + _window = null; + } } private void LoadGui() { + DebugTools.Assert(_window == null); + _window = UIManager.CreateWindow(); + LayoutContainer.SetAnchorPreset(_window, LayoutContainer.LayoutPreset.CenterTop); + + _window.OnOpen += OnWindowOpened; + _window.OnClose += OnWindowClosed; + _window.ClearButton.OnPressed += OnClearPressed; + _window.SearchBar.OnTextChanged += OnSearchChanged; + _window.FilterButton.OnItemSelected += OnFilterSelected; + if (ActionsBar == null) { return; @@ -791,7 +789,6 @@ private void LoadGui() ActionsBar.PageButtons.LeftArrow.OnPressed += OnLeftArrowPressed; ActionsBar.PageButtons.RightArrow.OnPressed += OnRightArrowPressed; - RegisterActionContainer(ActionsBar.ActionsContainer); _actionsSystem?.LinkAllActions(); diff --git a/Content.Client/Verbs/VerbSystem.cs b/Content.Client/Verbs/VerbSystem.cs index c55c3df841..1fcd7fd184 100644 --- a/Content.Client/Verbs/VerbSystem.cs +++ b/Content.Client/Verbs/VerbSystem.cs @@ -214,7 +214,7 @@ public void ExecuteVerb(EntityUid target, Verb verb) return; } - if (verb.ClientExclusive) + if (verb.ClientExclusive || target.IsClientSide()) // is this a client exclusive (gui) verb? ExecuteVerb(verb, user.Value, target); else diff --git a/Content.Client/Weapons/Melee/MeleeWindupOverlay.cs b/Content.Client/Weapons/Melee/MeleeWindupOverlay.cs index 72439de236..9f5193a3ca 100644 --- a/Content.Client/Weapons/Melee/MeleeWindupOverlay.cs +++ b/Content.Client/Weapons/Melee/MeleeWindupOverlay.cs @@ -104,7 +104,7 @@ protected override void Draw(in OverlayDrawArgs args) const float endX = 22f; // Area marking where to release - var releaseWidth = 2f * SharedMeleeWeaponSystem.GracePeriod / (float) comp.WindupTime.TotalSeconds * EyeManager.PixelsPerMeter; + var releaseWidth = 2f * SharedMeleeWeaponSystem.GracePeriod / (float) _melee.GetWindupTime(meleeUid, owner.Value, comp).TotalSeconds * EyeManager.PixelsPerMeter; const float releaseMiddle = (endX - startX) / 2f + startX; var releaseBox = new Box2(new Vector2(releaseMiddle - releaseWidth / 2f, 3f) / EyeManager.PixelsPerMeter, @@ -114,7 +114,7 @@ protected override void Draw(in OverlayDrawArgs args) handle.DrawRect(releaseBox, Color.LimeGreen); // Wraps around back to 0 - var totalDuration = comp.WindupTime.TotalSeconds * 2; + var totalDuration = _melee.GetWindupTime(meleeUid, owner.Value, comp).TotalSeconds * 2; var elapsed = (currentTime - comp.WindUpStart.Value).TotalSeconds % (2 * totalDuration); var value = elapsed / totalDuration; diff --git a/Content.IntegrationTests/Tests/Body/LegTest.cs b/Content.IntegrationTests/Tests/Body/LegTest.cs index d959db5da1..3930e3dfb3 100644 --- a/Content.IntegrationTests/Tests/Body/LegTest.cs +++ b/Content.IntegrationTests/Tests/Body/LegTest.cs @@ -35,14 +35,13 @@ public async Task RemoveLegsFallTest() var server = pairTracker.Pair.Server; AppearanceComponent appearance = null; + var entityManager = server.ResolveDependency(); + var mapManager = server.ResolveDependency(); await server.WaitAssertion(() => { - var mapManager = IoCManager.Resolve(); - var mapId = mapManager.CreateMap(); - var entityManager = IoCManager.Resolve(); var human = entityManager.SpawnEntity("HumanBodyAndAppearanceDummy", new MapCoordinates(Vector2.Zero, mapId)); @@ -52,7 +51,7 @@ await server.WaitAssertion(() => Assert.That(!appearance.TryGetData(RotationVisuals.RotationState, out RotationState _)); var bodySystem = entityManager.System(); - var legs = bodySystem.GetBodyChildrenOfType(body.Owner, BodyPartType.Leg, body); + var legs = bodySystem.GetBodyChildrenOfType(human, BodyPartType.Leg, body); foreach (var leg in legs) { diff --git a/Content.IntegrationTests/Tests/Cleanup/EuiManagerTest.cs b/Content.IntegrationTests/Tests/Cleanup/EuiManagerTest.cs index 43d9d3889d..78619f98f4 100644 --- a/Content.IntegrationTests/Tests/Cleanup/EuiManagerTest.cs +++ b/Content.IntegrationTests/Tests/Cleanup/EuiManagerTest.cs @@ -20,11 +20,11 @@ public async Task EuiManagerRecycleWithOpenWindowTest() var server = pairTracker.Pair.Server; var sPlayerManager = server.ResolveDependency(); + var eui = server.ResolveDependency(); await server.WaitAssertion(() => { var clientSession = sPlayerManager.ServerSessions.Single(); - var eui = IoCManager.Resolve(); var ui = new AdminAnnounceEui(); eui.OpenEui(ui, clientSession); }); diff --git a/Content.IntegrationTests/Tests/ContainerOcclusionTest.cs b/Content.IntegrationTests/Tests/ContainerOcclusionTest.cs index 4f1e72ed2f..0ea036d8e1 100644 --- a/Content.IntegrationTests/Tests/ContainerOcclusionTest.cs +++ b/Content.IntegrationTests/Tests/ContainerOcclusionTest.cs @@ -42,14 +42,15 @@ public async Task TestA() var c = pairTracker.Pair.Client; var cEntities = c.ResolveDependency(); + var ent = s.ResolveDependency(); EntityUid dummy = default; - var ent2 = s.ResolveDependency(); + var mapManager = s.ResolveDependency(); + var mapId = mapManager.CreateMap(); + await s.WaitPost(() => { - var mapId = ent2.GetAllMapIds().Last(); var pos = new MapCoordinates(Vector2.Zero, mapId); - var ent = IoCManager.Resolve(); var entStorage = ent.EntitySysManager.GetEntitySystem(); var container = ent.SpawnEntity("ContainerOcclusionA", pos); dummy = ent.SpawnEntity("ContainerOcclusionDummy", pos); @@ -78,14 +79,15 @@ public async Task TestB() var c = pairTracker.Pair.Client; var cEntities = c.ResolveDependency(); - var ent2 = s.ResolveDependency(); + var ent = s.ResolveDependency(); EntityUid dummy = default; + var mapManager = s.ResolveDependency(); + var mapId = mapManager.CreateMap(); + await s.WaitPost(() => { - var mapId = ent2.GetAllMapIds().Last(); var pos = new MapCoordinates(Vector2.Zero, mapId); - var ent = IoCManager.Resolve(); var entStorage = ent.EntitySysManager.GetEntitySystem(); var container = ent.SpawnEntity("ContainerOcclusionB", pos); dummy = ent.SpawnEntity("ContainerOcclusionDummy", pos); @@ -113,15 +115,16 @@ public async Task TestAb() var s = pairTracker.Pair.Server; var c = pairTracker.Pair.Client; - var ent2 = s.ResolveDependency(); var cEntities = c.ResolveDependency(); + var ent = s.ResolveDependency(); EntityUid dummy = default; + var mapManager = s.ResolveDependency(); + var mapId = mapManager.CreateMap(); + await s.WaitPost(() => { - var mapId = ent2.GetAllMapIds().Last(); var pos = new MapCoordinates(Vector2.Zero, mapId); - var ent = IoCManager.Resolve(); var entStorage = ent.EntitySysManager.GetEntitySystem(); var containerA = ent.SpawnEntity("ContainerOcclusionA", pos); var containerB = ent.SpawnEntity("ContainerOcclusionB", pos); diff --git a/Content.IntegrationTests/Tests/Damageable/DamageableTest.cs b/Content.IntegrationTests/Tests/Damageable/DamageableTest.cs index 35d8afe458..9920e15e02 100644 --- a/Content.IntegrationTests/Tests/Damageable/DamageableTest.cs +++ b/Content.IntegrationTests/Tests/Damageable/DamageableTest.cs @@ -105,7 +105,7 @@ await server.WaitPost(() => var coordinates = new MapCoordinates(0, 0, map); sDamageableEntity = sEntityManager.SpawnEntity("TestDamageableEntityId", coordinates); - sDamageableComponent = IoCManager.Resolve().GetComponent(sDamageableEntity); + sDamageableComponent = sEntityManager.GetComponent(sDamageableEntity); sDamageableSystem = sEntitySystemManager.GetEntitySystem(); group1 = sPrototypeManager.Index("TestGroup1"); diff --git a/Content.IntegrationTests/Tests/DeleteInventoryTest.cs b/Content.IntegrationTests/Tests/DeleteInventoryTest.cs index 6b43d904c8..d1d557964b 100644 --- a/Content.IntegrationTests/Tests/DeleteInventoryTest.cs +++ b/Content.IntegrationTests/Tests/DeleteInventoryTest.cs @@ -20,14 +20,15 @@ public async Task Test() await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings{NoClient = true}); var server = pairTracker.Pair.Server; var testMap = await PoolManager.CreateTestMap(pairTracker); + var entMgr = server.ResolveDependency(); + var sysManager = server.ResolveDependency(); var coordinates = testMap.GridCoords; await server.WaitAssertion(() => { // Spawn everything. - var invSystem = IoCManager.Resolve().GetEntitySystem(); + var invSystem = sysManager.GetEntitySystem(); - var entMgr = IoCManager.Resolve(); var container = entMgr.SpawnEntity(null, coordinates); entMgr.EnsureComponent(container); entMgr.EnsureComponent(container); @@ -35,7 +36,7 @@ await server.WaitAssertion(() => var child = entMgr.SpawnEntity(null, coordinates); var item = entMgr.EnsureComponent(child); - IoCManager.Resolve().GetEntitySystem().SetSlots(item.Owner, SlotFlags.HEAD, item); + sysManager.GetEntitySystem().SetSlots(child, SlotFlags.HEAD, item); // Equip item. Assert.That(invSystem.TryEquip(container, child, "head"), Is.True); diff --git a/Content.IntegrationTests/Tests/Destructible/DestructibleDamageTypeTest.cs b/Content.IntegrationTests/Tests/Destructible/DestructibleDamageTypeTest.cs index 29c25be5d9..3cc917add9 100644 --- a/Content.IntegrationTests/Tests/Destructible/DestructibleDamageTypeTest.cs +++ b/Content.IntegrationTests/Tests/Destructible/DestructibleDamageTypeTest.cs @@ -26,6 +26,7 @@ public async Task Test() var sEntityManager = server.ResolveDependency(); var sEntitySystemManager = server.ResolveDependency(); + var protoManager = server.ResolveDependency(); EntityUid sDestructibleEntity = default; DamageableComponent sDamageableComponent = null; @@ -37,7 +38,7 @@ await server.WaitPost(() => var coordinates = testMap.GridCoords; sDestructibleEntity = sEntityManager.SpawnEntity(DestructibleDamageTypeEntityId, coordinates); - sDamageableComponent = IoCManager.Resolve().GetComponent(sDestructibleEntity); + sDamageableComponent = sEntityManager.GetComponent(sDestructibleEntity); sTestThresholdListenerSystem = sEntitySystemManager.GetEntitySystem(); sTestThresholdListenerSystem.ThresholdsReached.Clear(); sDamageableSystem = sEntitySystemManager.GetEntitySystem(); @@ -52,8 +53,8 @@ await server.WaitAssertion(() => await server.WaitAssertion(() => { - var bluntDamageType = IoCManager.Resolve().Index("TestBlunt"); - var slashDamageType = IoCManager.Resolve().Index("TestSlash"); + var bluntDamageType = protoManager.Index("TestBlunt"); + var slashDamageType = protoManager.Index("TestSlash"); var bluntDamage = new DamageSpecifier(bluntDamageType,5); var slashDamage = new DamageSpecifier(slashDamageType,5); diff --git a/Content.IntegrationTests/Tests/Destructible/DestructibleDestructionTest.cs b/Content.IntegrationTests/Tests/Destructible/DestructibleDestructionTest.cs index be26c38c59..c8a38734e7 100644 --- a/Content.IntegrationTests/Tests/Destructible/DestructibleDestructionTest.cs +++ b/Content.IntegrationTests/Tests/Destructible/DestructibleDestructionTest.cs @@ -41,7 +41,7 @@ await server.WaitPost(() => await server.WaitAssertion(() => { - var coordinates = IoCManager.Resolve().GetComponent(sDestructibleEntity).Coordinates; + var coordinates = sEntityManager.GetComponent(sDestructibleEntity).Coordinates; var bruteDamageGroup = sPrototypeManager.Index("TestBrute"); DamageSpecifier bruteDamage = new(bruteDamageGroup,50); diff --git a/Content.IntegrationTests/Tests/Disposal/DisposalUnitTest.cs b/Content.IntegrationTests/Tests/Disposal/DisposalUnitTest.cs index c383335241..4d8616bd81 100644 --- a/Content.IntegrationTests/Tests/Disposal/DisposalUnitTest.cs +++ b/Content.IntegrationTests/Tests/Disposal/DisposalUnitTest.cs @@ -156,6 +156,8 @@ public async Task Test() EntityUid wrench = default!; EntityUid disposalUnit = default!; EntityUid disposalTrunk = default!; + + EntityUid unitUid = default; DisposalUnitComponent unitComponent = default!; var entityManager = server.ResolveDependency(); @@ -168,22 +170,22 @@ await server.WaitAssertion(() => wrench = entityManager.SpawnEntity("WrenchDummy", coordinates); disposalUnit = entityManager.SpawnEntity("DisposalUnitDummy", coordinates); disposalTrunk = entityManager.SpawnEntity("DisposalTrunkDummy", - IoCManager.Resolve().GetComponent(disposalUnit).MapPosition); + entityManager.GetComponent(disposalUnit).MapPosition); // Test for components existing - ref DisposalUnitComponent? comp = ref unitComponent!; - Assert.True(entityManager.TryGetComponent(disposalUnit, out comp)); + unitUid = disposalUnit; + Assert.True(entityManager.TryGetComponent(disposalUnit, out unitComponent)); Assert.True(entityManager.HasComponent(disposalTrunk)); // Can't insert, unanchored and unpowered - entityManager.GetComponent(unitComponent!.Owner).Anchored = false; + entityManager.GetComponent(unitUid).Anchored = false; UnitInsertContains(unitComponent, false, human, wrench, disposalUnit, disposalTrunk); }); await server.WaitAssertion(() => { // Anchor the disposal unit - entityManager.GetComponent(unitComponent.Owner).Anchored = true; + entityManager.GetComponent(unitUid).Anchored = true; // No power Assert.False(unitComponent.Powered); diff --git a/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs b/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs index db2df0ade2..390bdff8d3 100644 --- a/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs +++ b/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs @@ -67,13 +67,14 @@ public async Task TestFinished() await server.WaitIdleAsync(); var entityManager = server.ResolveDependency(); + var timing = server.ResolveDependency(); var doAfterSystem = entityManager.EntitySysManager.GetEntitySystem(); var ev = new TestDoAfterEvent(); // That it finishes successfully await server.WaitPost(() => { - var tickTime = 1.0f / IoCManager.Resolve().TickRate; + var tickTime = 1.0f / timing.TickRate; var mob = entityManager.SpawnEntity("Dummy", MapCoordinates.Nullspace); var args = new DoAfterArgs(mob, tickTime / 2, ev, null) { Broadcast = true }; Assert.That(doAfterSystem.TryStartDoAfter(args)); @@ -92,14 +93,14 @@ public async Task TestCancelled() await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings{NoClient = true, ExtraPrototypes = Prototypes}); var server = pairTracker.Pair.Server; var entityManager = server.ResolveDependency(); + var timing = server.ResolveDependency(); var doAfterSystem = entityManager.EntitySysManager.GetEntitySystem(); - DoAfterId? id = default; + DoAfterId? id; var ev = new TestDoAfterEvent(); - await server.WaitPost(() => { - var tickTime = 1.0f / IoCManager.Resolve().TickRate; + var tickTime = 1.0f / timing.TickRate; var mob = entityManager.SpawnEntity("Dummy", MapCoordinates.Nullspace); var args = new DoAfterArgs(mob, tickTime * 2, ev, null) { Broadcast = true }; diff --git a/Content.IntegrationTests/Tests/DummyIconTest.cs b/Content.IntegrationTests/Tests/DummyIconTest.cs index f8351848cf..d51da122d4 100644 --- a/Content.IntegrationTests/Tests/DummyIconTest.cs +++ b/Content.IntegrationTests/Tests/DummyIconTest.cs @@ -17,14 +17,15 @@ public async Task Test() { await using var pairTracker = await PoolManager.GetServerClient(); var client = pairTracker.Pair.Client; + var prototypeManager = client.ResolveDependency(); + var resourceCache = client.ResolveDependency(); await client.WaitAssertion(() => { - var prototypeManager = IoCManager.Resolve(); - var resourceCache = IoCManager.Resolve(); foreach (var proto in prototypeManager.EnumeratePrototypes()) { - if (proto.NoSpawn || proto.Abstract || !proto.Components.ContainsKey("Sprite")) continue; + if (proto.NoSpawn || proto.Abstract || !proto.Components.ContainsKey("Sprite")) + continue; Assert.DoesNotThrow(() => { diff --git a/Content.IntegrationTests/Tests/FollowerSystemTest.cs b/Content.IntegrationTests/Tests/FollowerSystemTest.cs index 823c94b488..ceef0f35b6 100644 --- a/Content.IntegrationTests/Tests/FollowerSystemTest.cs +++ b/Content.IntegrationTests/Tests/FollowerSystemTest.cs @@ -22,12 +22,14 @@ public async Task FollowerMapDeleteTest() await using var pairTracker = await PoolManager.GetServerClient(new (){NoClient = true}); var server = pairTracker.Pair.Server; + var entMan = server.ResolveDependency(); + var mapMan = server.ResolveDependency(); + var sysMan = server.ResolveDependency(); + var logMan = server.ResolveDependency(); + var logger = logMan.RootSawmill; + await server.WaitPost(() => { - var mapMan = IoCManager.Resolve(); - var entMan = IoCManager.Resolve(); - var sysMan = IoCManager.Resolve(); - var logger = IoCManager.Resolve().RootSawmill; var followerSystem = sysMan.GetEntitySystem(); // Create a map to spawn the observers on. diff --git a/Content.IntegrationTests/Tests/GameObjects/Components/ActionBlocking/HandCuffTest.cs b/Content.IntegrationTests/Tests/GameObjects/Components/ActionBlocking/HandCuffTest.cs index df660c7761..478972d3ea 100644 --- a/Content.IntegrationTests/Tests/GameObjects/Components/ActionBlocking/HandCuffTest.cs +++ b/Content.IntegrationTests/Tests/GameObjects/Components/ActionBlocking/HandCuffTest.cs @@ -49,13 +49,15 @@ public async Task Test() CuffableComponent cuffed; HandsComponent hands; + var entityManager = server.ResolveDependency(); + var mapManager = server.ResolveDependency(); + var host = server.ResolveDependency(); + await server.WaitAssertion(() => { - var mapManager = IoCManager.Resolve(); var mapId = mapManager.CreateMap(); var coordinates = new MapCoordinates(Vector2.Zero, mapId); - var entityManager = IoCManager.Resolve(); var cuffableSys = entityManager.System(); // Spawn the entities @@ -81,8 +83,8 @@ await server.WaitAssertion(() => "Handcuffing a player did not result in their hands being cuffed"); // Test to ensure a player with 4 hands will still only have 2 hands cuffed - AddHand(human); - AddHand(human); + AddHand(human, host); + AddHand(human, host); Assert.That(cuffed.CuffedHandCount, Is.EqualTo(2)); Assert.That(hands.SortedHands.Count, Is.EqualTo(4)); @@ -95,9 +97,8 @@ await server.WaitAssertion(() => await pairTracker.CleanReturnAsync(); } - private void AddHand(EntityUid to) + private void AddHand(EntityUid to, IServerConsoleHost host) { - var host = IoCManager.Resolve(); host.ExecuteCommand(null, $"addhand {to}"); } } diff --git a/Content.IntegrationTests/Tests/GravityGridTest.cs b/Content.IntegrationTests/Tests/GravityGridTest.cs index d4634af6f8..b1ea932b9e 100644 --- a/Content.IntegrationTests/Tests/GravityGridTest.cs +++ b/Content.IntegrationTests/Tests/GravityGridTest.cs @@ -38,6 +38,7 @@ public async Task Test() EntityUid generator = default; var entityMan = server.ResolveDependency(); + var mapMan = server.ResolveDependency(); MapGridComponent grid1 = null; MapGridComponent grid2 = null; @@ -45,8 +46,6 @@ public async Task Test() // Create grids await server.WaitAssertion(() => { - var mapMan = IoCManager.Resolve(); - var mapId = testMap.MapId; grid1 = mapMan.CreateGrid(mapId); grid2 = mapMan.CreateGrid(mapId); diff --git a/Content.IntegrationTests/Tests/HumanInventoryUniformSlotsTest.cs b/Content.IntegrationTests/Tests/HumanInventoryUniformSlotsTest.cs index 65e68d7b9a..9aa19d8d4f 100644 --- a/Content.IntegrationTests/Tests/HumanInventoryUniformSlotsTest.cs +++ b/Content.IntegrationTests/Tests/HumanInventoryUniformSlotsTest.cs @@ -69,12 +69,11 @@ public async Task Test() EntityUid pocketItem = default; InventorySystem invSystem = default!; + var entityMan = server.ResolveDependency(); await server.WaitAssertion(() => { - invSystem = IoCManager.Resolve().GetEntitySystem(); - var mapMan = IoCManager.Resolve(); - var entityMan = IoCManager.Resolve(); + invSystem = entityMan.System(); human = entityMan.SpawnEntity("HumanDummy", coordinates); uniform = entityMan.SpawnEntity("UniformDummy", coordinates); @@ -96,8 +95,8 @@ await server.WaitAssertion(() => Assert.That(invSystem.CanEquip(human, tooBigItem, "pocket1", out _), Is.False); // Still failing! Assert.That(invSystem.TryEquip(human, pocketItem, "pocket1")); - Assert.That(IsDescendant(idCard, human)); - Assert.That(IsDescendant(pocketItem, human)); + Assert.That(IsDescendant(idCard, human, entityMan)); + Assert.That(IsDescendant(pocketItem, human, entityMan)); // Now drop the jumpsuit. Assert.That(invSystem.TryUnequip(human, "jumpsuit")); @@ -108,9 +107,9 @@ await server.WaitAssertion(() => await server.WaitAssertion(() => { // Items have been dropped! - Assert.That(IsDescendant(uniform, human), Is.False); - Assert.That(IsDescendant(idCard, human), Is.False); - Assert.That(IsDescendant(pocketItem, human), Is.False); + Assert.That(IsDescendant(uniform, human, entityMan), Is.False); + Assert.That(IsDescendant(idCard, human, entityMan), Is.False); + Assert.That(IsDescendant(pocketItem, human, entityMan), Is.False); // Ensure everything null here. Assert.That(!invSystem.TryGetSlotEntity(human, "jumpsuit", out _)); @@ -121,9 +120,9 @@ await server.WaitAssertion(() => await pairTracker.CleanReturnAsync(); } - private static bool IsDescendant(EntityUid descendant, EntityUid parent) + private static bool IsDescendant(EntityUid descendant, EntityUid parent, IEntityManager entManager) { - var xforms = IoCManager.Resolve().GetEntityQuery(); + var xforms = entManager.GetEntityQuery(); var tmpParent = xforms.GetComponent(descendant).ParentUid; while (tmpParent.IsValid()) { diff --git a/Content.IntegrationTests/Tests/InventoryHelpersTest.cs b/Content.IntegrationTests/Tests/InventoryHelpersTest.cs index 6a7d8c8c13..d1fafa0d73 100644 --- a/Content.IntegrationTests/Tests/InventoryHelpersTest.cs +++ b/Content.IntegrationTests/Tests/InventoryHelpersTest.cs @@ -47,11 +47,10 @@ public async Task SpawnItemInSlotTest() var server = pairTracker.Pair.Server; var sEntities = server.ResolveDependency(); + var systemMan = sEntities.EntitySysManager; await server.WaitAssertion(() => { - var mapMan = IoCManager.Resolve(); - var systemMan = IoCManager.Resolve(); var human = sEntities.SpawnEntity("InventoryStunnableDummy", MapCoordinates.Nullspace); var invSystem = systemMan.GetEntitySystem(); diff --git a/Content.IntegrationTests/Tests/Lobby/ServerReloginTest.cs b/Content.IntegrationTests/Tests/Lobby/ServerReloginTest.cs index f9e6df00b3..2e2cfa4f58 100644 --- a/Content.IntegrationTests/Tests/Lobby/ServerReloginTest.cs +++ b/Content.IntegrationTests/Tests/Lobby/ServerReloginTest.cs @@ -13,18 +13,18 @@ public sealed class ServerReloginTest [Test] public async Task Relogin() { - IConfigurationManager serverConfig = default; - IPlayerManager serverPlayerMgr = default; - IClientNetManager clientNetManager = default; await using var pairTracker = await PoolManager.GetServerClient(); var server = pairTracker.Pair.Server; var client = pairTracker.Pair.Client; int originalMaxPlayers = 0; string username = null; + + var serverConfig = server.ResolveDependency(); + var serverPlayerMgr = server.ResolveDependency(); + var clientNetManager = client.ResolveDependency(); + await server.WaitAssertion(() => { - serverConfig = IoCManager.Resolve(); - serverPlayerMgr = IoCManager.Resolve(); Assert.That(serverPlayerMgr.PlayerCount, Is.EqualTo(1)); originalMaxPlayers = serverConfig.GetCVar(CCVars.SoftMaxPlayers); username = serverPlayerMgr.Sessions.First().Name; @@ -35,7 +35,6 @@ await server.WaitAssertion(() => await client.WaitAssertion(() => { - clientNetManager = IoCManager.Resolve(); clientNetManager.ClientDisconnect("For testing"); }); diff --git a/Content.IntegrationTests/Tests/MachineBoardTest.cs b/Content.IntegrationTests/Tests/MachineBoardTest.cs index e1c1718cfb..5ffe0cf91c 100644 --- a/Content.IntegrationTests/Tests/MachineBoardTest.cs +++ b/Content.IntegrationTests/Tests/MachineBoardTest.cs @@ -20,9 +20,9 @@ public sealed class MachineBoardTest "MachineParticleAcceleratorFuelChamberCircuitboard", "MachineParticleAcceleratorFuelChamberCircuitboard", "MachineParticleAcceleratorPowerBoxCircuitboard", - "MachineParticleAcceleratorEmitterLeftCircuitboard", - "MachineParticleAcceleratorEmitterCenterCircuitboard", - "MachineParticleAcceleratorEmitterRightCircuitboard", + "MachineParticleAcceleratorEmitterStarboardCircuitboard", + "MachineParticleAcceleratorEmitterForeCircuitboard", + "MachineParticleAcceleratorEmitterPortCircuitboard", "ParticleAcceleratorComputerCircuitboard" }; @@ -33,7 +33,7 @@ public sealed class MachineBoardTest [Test] public async Task TestMachineBoardHasValidMachine() { - await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings{NoClient = true}); + await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings { NoClient = true }); var server = pairTracker.Pair.Server; var protoMan = server.ResolveDependency(); @@ -46,13 +46,16 @@ await server.WaitAssertion(() => continue; var mId = mbc.Prototype; - Assert.That(mId, Is.Not.Null, $"Machine board {p.ID} does not have a corresponding machine."); - Assert.That(protoMan.TryIndex(mId, out var mProto), - $"Machine board {p.ID}'s corresponding machine has an invalid prototype."); - Assert.That(mProto.TryGetComponent(out var mComp), - $"Machine board {p.ID}'s corresponding machine {mId} does not have MachineComponent"); - Assert.That(mComp.BoardPrototype, Is.EqualTo(p.ID), - $"Machine {mId}'s BoardPrototype is not equal to it's corresponding machine board, {p.ID}"); + Assert.Multiple(() => + { + Assert.That(mId, Is.Not.Null, $"Machine board {p.ID} does not have a corresponding machine."); + Assert.That(protoMan.TryIndex(mId, out var mProto), + $"Machine board {p.ID}'s corresponding machine has an invalid prototype."); + Assert.That(mProto.TryGetComponent(out var mComp), + $"Machine board {p.ID}'s corresponding machine {mId} does not have MachineComponent"); + Assert.That(mComp.BoardPrototype, Is.EqualTo(p.ID), + $"Machine {mId}'s BoardPrototype is not equal to it's corresponding machine board, {p.ID}"); + }); } }); @@ -66,7 +69,7 @@ await server.WaitAssertion(() => [Test] public async Task TestComputerBoardHasValidComputer() { - await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings{NoClient = true}); + await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings { NoClient = true }); var server = pairTracker.Pair.Server; var protoMan = server.ResolveDependency(); @@ -79,13 +82,16 @@ await server.WaitAssertion(() => continue; var cId = cbc.Prototype; - Assert.That(cId, Is.Not.Null, $"Computer board \"{p.ID}\" does not have a corresponding computer."); - Assert.That(protoMan.TryIndex(cId, out var cProto), - $"Computer board \"{p.ID}\"'s corresponding computer has an invalid prototype."); - Assert.That(cProto.TryGetComponent(out var cComp), - $"Computer board {p.ID}'s corresponding computer \"{cId}\" does not have ComputerComponent"); - Assert.That(cComp.BoardPrototype, Is.EqualTo(p.ID), - $"Computer \"{cId}\"'s BoardPrototype is not equal to it's corresponding computer board, \"{p.ID}\""); + Assert.Multiple(() => + { + Assert.That(cId, Is.Not.Null, $"Computer board \"{p.ID}\" does not have a corresponding computer."); + Assert.That(protoMan.TryIndex(cId, out var cProto), + $"Computer board \"{p.ID}\"'s corresponding computer has an invalid prototype."); + Assert.That(cProto.TryGetComponent(out var cComp), + $"Computer board {p.ID}'s corresponding computer \"{cId}\" does not have ComputerComponent"); + Assert.That(cComp.BoardPrototype, Is.EqualTo(p.ID), + $"Computer \"{cId}\"'s BoardPrototype is not equal to it's corresponding computer board, \"{p.ID}\""); + }); } }); diff --git a/Content.IntegrationTests/Tests/Networking/ReconnectTest.cs b/Content.IntegrationTests/Tests/Networking/ReconnectTest.cs index 5f97339afd..893694d509 100644 --- a/Content.IntegrationTests/Tests/Networking/ReconnectTest.cs +++ b/Content.IntegrationTests/Tests/Networking/ReconnectTest.cs @@ -16,7 +16,10 @@ public async Task Test() var server = pairTracker.Pair.Server; var client = pairTracker.Pair.Client; - await client.WaitPost(() => IoCManager.Resolve().ExecuteCommand("disconnect")); + var host = client.ResolveDependency(); + var netManager = client.ResolveDependency(); + + await client.WaitPost(() => host.ExecuteCommand("disconnect")); // Run some ticks for the disconnect to complete and such. await PoolManager.RunTicksSync(pairTracker.Pair, 5); @@ -26,7 +29,7 @@ public async Task Test() // Reconnect. client.SetConnectTarget(server); - await client.WaitPost(() => IoCManager.Resolve().ClientConnect(null, 0, null)); + await client.WaitPost(() => netManager.ClientConnect(null, 0, null)); // Run some ticks for the handshake to complete and such. await PoolManager.RunTicksSync(pairTracker.Pair, 10); diff --git a/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs b/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs index 869be4c4fe..b7000b5d20 100644 --- a/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs +++ b/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs @@ -70,7 +70,7 @@ await server.WaitPost(() => var map = sMapManager.CreateMap(); var player = sPlayerManager.ServerSessions.Single(); serverEnt = sEntityManager.SpawnEntity(null, new MapCoordinates((0, 0), map)); - serverComponent = IoCManager.Resolve().AddComponent(serverEnt); + serverComponent = sEntityManager.AddComponent(serverEnt); // Make client "join game" so they receive game state updates. player.JoinGame(); @@ -364,7 +364,7 @@ await client.WaitPost(() => Assert.That(clientComponent.Foo, Is.True); } } - + cfg.SetCVar(CVars.NetLogging, log); await pairTracker.CleanReturnAsync(); } @@ -432,7 +432,7 @@ public override void Initialize() private void HandleMessage(SetFooMessage message, EntitySessionEventArgs args) { - var component = IoCManager.Resolve().GetComponent(message.Uid); + var component = EntityManager.GetComponent(message.Uid); var old = component.Foo; if (Allow) { diff --git a/Content.IntegrationTests/Tests/RestartRoundTest.cs b/Content.IntegrationTests/Tests/RestartRoundTest.cs index 6798700224..f54bd74e22 100644 --- a/Content.IntegrationTests/Tests/RestartRoundTest.cs +++ b/Content.IntegrationTests/Tests/RestartRoundTest.cs @@ -14,10 +14,11 @@ public async Task Test() { await using var pairTracker = await PoolManager.GetServerClient(); var server = pairTracker.Pair.Server; + var sysManager = server.ResolveDependency(); await server.WaitPost(() => { - IoCManager.Resolve().GetEntitySystem().RestartRound(); + sysManager.GetEntitySystem().RestartRound(); }); await PoolManager.RunTicksSync(pairTracker.Pair, 10); diff --git a/Content.IntegrationTests/Tests/RoundEndTest.cs b/Content.IntegrationTests/Tests/RoundEndTest.cs index 9e4b71d8c2..2b8143038a 100644 --- a/Content.IntegrationTests/Tests/RoundEndTest.cs +++ b/Content.IntegrationTests/Tests/RoundEndTest.cs @@ -22,6 +22,7 @@ public async Task Test() var server = pairTracker.Pair.Server; + var entManager = server.ResolveDependency(); var config = server.ResolveDependency(); var sysManager = server.ResolveDependency(); var ticker = sysManager.GetEntitySystem(); @@ -42,7 +43,7 @@ await server.WaitAssertion(() => await server.WaitAssertion(() => { - var bus = IoCManager.Resolve().EventBus; + var bus = entManager.EventBus; bus.SubscribeEvent(EventSource.Local, this, _ => { Interlocked.Increment(ref eventCount); }); diff --git a/Content.Replay/Content.Replay.csproj b/Content.Replay/Content.Replay.csproj new file mode 100644 index 0000000000..5d5880f23a --- /dev/null +++ b/Content.Replay/Content.Replay.csproj @@ -0,0 +1,26 @@ + + + $(TargetFramework) + 10 + false + false + ..\bin\Content.Replay\ + Exe + nullable + enable + + + + + + + + + + + + + + + + diff --git a/Content.Replay/EntryPoint.cs b/Content.Replay/EntryPoint.cs new file mode 100644 index 0000000000..ed6460a7e7 --- /dev/null +++ b/Content.Replay/EntryPoint.cs @@ -0,0 +1,34 @@ +using Content.Client.Replay; +using Content.Replay.Menu; +using JetBrains.Annotations; +using Robust.Client; +using Robust.Client.Console; +using Robust.Client.State; +using Robust.Shared.ContentPack; + +namespace Content.Replay; + +[UsedImplicitly] +public sealed class EntryPoint : GameClient +{ + [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly IStateManager _stateMan = default!; + [Dependency] private readonly ContentReplayPlaybackManager _contentReplayPlaybackMan = default!; + [Dependency] private readonly IClientConGroupController _conGrp = default!; + + public override void Init() + { + base.Init(); + IoCManager.BuildGraph(); + IoCManager.InjectDependencies(this); + } + + public override void PostInit() + { + base.PostInit(); + _client.StartSinglePlayer(); + _conGrp.Implementation = new ReplayConGroup(); + _contentReplayPlaybackMan.DefaultState = typeof(ReplayMainScreen); + _stateMan.RequestStateChange(); + } +} diff --git a/Content.Replay/GlobalUsings.cs b/Content.Replay/GlobalUsings.cs new file mode 100644 index 0000000000..f0cbfd5b1a --- /dev/null +++ b/Content.Replay/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Global usings for Content.Replay + +global using System; +global using System.Collections.Generic; +global using Robust.Shared.Log; +global using Robust.Shared.Localization; +global using Robust.Shared.GameObjects; +global using Robust.Shared.IoC; +global using Robust.Shared.Maths; diff --git a/Content.Replay/Menu/ReplayMainMenu.cs b/Content.Replay/Menu/ReplayMainMenu.cs new file mode 100644 index 0000000000..6748c20988 --- /dev/null +++ b/Content.Replay/Menu/ReplayMainMenu.cs @@ -0,0 +1,283 @@ +using System.Linq; +using Content.Client.Message; +using Content.Client.UserInterface.Systems.EscapeMenu; +using Robust.Client; +using Robust.Client.Replays.Loading; +using Robust.Client.ResourceManagement; +using Robust.Client.Serialization; +using Robust.Client.State; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.Serialization.Markdown.Value; +using Robust.Shared.Utility; +using TerraFX.Interop.Windows; +using static Robust.Shared.Replays.IReplayRecordingManager; +using IResourceManager = Robust.Shared.ContentPack.IResourceManager; + +namespace Content.Replay.Menu; + +/// +/// Main menu screen for selecting and loading replays. +/// +public sealed class ReplayMainScreen : State +{ + [Dependency] private readonly IResourceManager _resMan = default!; + [Dependency] private readonly IComponentFactory _factory = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IReplayLoadManager _loadMan = default!; + [Dependency] private readonly IResourceCache _resourceCache = default!; + [Dependency] private readonly IGameController _controllerProxy = default!; + [Dependency] private readonly IClientRobustSerializer _serializer = default!; + [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; + + private ReplayMainMenuControl _mainMenuControl = default!; + private SelectReplayWindow? _selectWindow; + private ResPath _directory; + private List<(string Name, ResPath Path)> _replays = new(); + private ResPath? _selected; + + protected override void Startup() + { + _mainMenuControl = new(_resourceCache); + _userInterfaceManager.StateRoot.AddChild(_mainMenuControl); + + _mainMenuControl.SelectButton.OnPressed += OnSelectPressed; + _mainMenuControl.QuitButton.OnPressed += QuitButtonPressed; + _mainMenuControl.OptionsButton.OnPressed += OptionsButtonPressed; + _mainMenuControl.FolderButton.OnPressed += OnFolderPressed; + _mainMenuControl.LoadButton.OnPressed += OnLoadpressed; + + _directory = new ResPath(_cfg.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); + RefreshReplays(); + SelectReplay(_replays.FirstOrNull()?.Path); + if (_selected == null) // force initial update + UpdateSelectedInfo(); + } + + /// + /// Read replay meta-data and update the replay info box. + /// + private void UpdateSelectedInfo() + { + var info = _mainMenuControl.Info; + + if (_selected is not { } replay) + { + info.SetMarkup(Loc.GetString("replay-info-none-selected")); + info.HorizontalAlignment = Control.HAlignment.Center; + info.VerticalAlignment = Control.VAlignment.Center; + _mainMenuControl.LoadButton.Disabled = true; + return; + } + + if (!_resMan.UserData.Exists(replay) + || _loadMan.LoadYamlMetadata(_resMan.UserData, replay) is not { } data) + { + info.SetMarkup(Loc.GetString("replay-info-invalid")); + info.HorizontalAlignment = Control.HAlignment.Center; + info.VerticalAlignment = Control.VAlignment.Center; + _mainMenuControl.LoadButton.Disabled = true; + return; + } + + var file = replay.ToRelativePath().ToString(); + data.TryGet(Time, out var timeNode); + data.TryGet(Duration, out var durationNode); + data.TryGet("roundId", out var roundIdNode); + data.TryGet(Hash, out var hashNode); + data.TryGet(CompHash, out var compHashNode); + DateTime.TryParse(timeNode?.Value, out var time); + TimeSpan.TryParse(durationNode?.Value, out var duration); + + var forkId = string.Empty; + if (data.TryGet(Fork, out var forkNode)) + { + // TODO REPLAYS somehow distribute and load from build.json? + var clientFork = _cfg.GetCVar(CVars.BuildForkId); + if (string.IsNullOrWhiteSpace(clientFork)) + forkId = forkNode.Value; + else if (forkNode.Value == clientFork) + forkId = $"[color=green]{forkNode.Value}[/color]"; + else + forkId = $"[color=yellow]{forkNode.Value}[/color]"; + } + + var forkVersion = string.Empty; + if (data.TryGet(ForkVersion, out var versionNode)) + { + forkVersion = versionNode.Value; + // Why does this not have a try-convert function? I just want to check if it looks like a hash code. + try + { + Convert.FromHexString(forkVersion); + // version is a probably some git commit. Crop it to keep the info box small. + forkVersion = forkVersion[..16]; + } + catch + { + // ignored + } + + // TODO REPLAYS somehow distribute and load from build.json? + var clientVer = _cfg.GetCVar(CVars.BuildVersion); + if (!string.IsNullOrWhiteSpace(clientVer)) + { + if (versionNode.Value == clientVer) + forkVersion = $"[color=green]{forkVersion}[/color]"; + else + forkVersion = $"[color=yellow]{forkVersion}[/color]"; + } + } + + if (hashNode == null) + throw new Exception("Invalid metadata file. Missing type hash"); + + var typeHash = hashNode.Value; + _mainMenuControl.LoadButton.Disabled = false; + if (Convert.FromHexString(typeHash).SequenceEqual(_serializer.GetSerializableTypesHash())) + { + typeHash = $"[color=green]{typeHash[..16]}[/color]"; + } + else + { + typeHash = $"[color=red]{typeHash[..16]}[/color]"; + _mainMenuControl.LoadButton.Disabled = true; + } + + if (compHashNode == null) + throw new Exception("Invalid metadata file. Missing component hash"); + + var compHash = compHashNode.Value; + if (Convert.FromHexString(compHash).SequenceEqual(_factory.GetHash(true))) + { + compHash = $"[color=green]{compHash[..16]}[/color]"; + _mainMenuControl.LoadButton.Disabled = false; + } + else + { + compHash = $"[color=red]{compHash[..16]}[/color]"; + _mainMenuControl.LoadButton.Disabled = true; + } + + var engineVersion = string.Empty; + if (data.TryGet(Engine, out var engineNode)) + { + var clientVer = _cfg.GetCVar(CVars.BuildEngineVersion); + if (string.IsNullOrWhiteSpace(clientVer)) + engineVersion = engineNode.Value; + else if (engineNode.Value == clientVer) + engineVersion = $"[color=green]{engineNode.Value}[/color]"; + else + engineVersion = $"[color=yellow]{engineNode.Value}[/color]"; + } + + // Strip milliseconds. Apparently there is no general format string that suppresses milliseconds. + duration = new((int)Math.Floor(duration.TotalDays), duration.Hours, duration.Minutes, duration.Seconds); + + data.TryGet(Name, out var nameNode); + var name = nameNode?.Value ?? string.Empty; + + info.HorizontalAlignment = Control.HAlignment.Left; + info.VerticalAlignment = Control.VAlignment.Top; + info.SetMarkup(Loc.GetString( + "replay-info-info", + ("file", file), + ("name", name), + ("time", time), + ("roundId", roundIdNode?.Value ?? "???"), + ("duration", duration), + ("forkId", forkId), + ("version", forkVersion), + ("engVersion", engineVersion), + ("compHash", compHash), + ("hash", typeHash))); + } + + private void OnFolderPressed(BaseButton.ButtonEventArgs obj) + { + _resMan.UserData.CreateDir(_directory); + _resMan.UserData.OpenOsWindow(_directory); + } + + private void OnLoadpressed(BaseButton.ButtonEventArgs obj) + { + if (_selected.HasValue) + _loadMan.LoadAndStartReplay(_resMan.UserData, _selected.Value); + } + + private void RefreshReplays() + { + _replays.Clear(); + + foreach (var entry in _resMan.UserData.DirectoryEntries(_directory)) + { + var file = _directory / entry; + try + { + var data = _loadMan.LoadYamlMetadata(_resMan.UserData, file); + if (data == null) + continue; + + var name = data.Get(Name).Value; + _replays.Add((name, file)); + + } + catch + { + // Ignore file + } + } + + _selectWindow?.Repopulate(_replays); + if (_selected.HasValue && _replays.All(x => x.Path != _selected.Value)) + SelectReplay(null); + else + _selectWindow?.UpdateSelected(_selected); + } + + public void SelectReplay(ResPath? replay) + { + if (_selected == replay) + return; + + _selected = replay; + try + { + UpdateSelectedInfo(); + } + catch (Exception ex) + { + Logger.Error($"Failed to load replay info. Exception: {ex}"); + SelectReplay(null); + return; + } + _selectWindow?.UpdateSelected(replay); + } + + protected override void Shutdown() + { + _mainMenuControl.Dispose(); + _selectWindow?.Dispose(); + } + + private void OptionsButtonPressed(BaseButton.ButtonEventArgs args) + { + _userInterfaceManager.GetUIController().ToggleWindow(); + } + + private void QuitButtonPressed(BaseButton.ButtonEventArgs args) + { + _controllerProxy.Shutdown(); + } + + private void OnSelectPressed(BaseButton.ButtonEventArgs args) + { + RefreshReplays(); + _selectWindow ??= new(this); + _selectWindow.Repopulate(_replays); + _selectWindow.UpdateSelected(_selected); + _selectWindow.OpenCentered(); + } +} diff --git a/Content.Replay/Menu/ReplayMainMenuControl.xaml b/Content.Replay/Menu/ReplayMainMenuControl.xaml new file mode 100644 index 0000000000..f26db1121f --- /dev/null +++ b/Content.Replay/Menu/ReplayMainMenuControl.xaml @@ -0,0 +1,52 @@ + + + + + +