diff --git a/Content.Client/GameObjects/Components/CloningPod/CloningPodWindow.cs b/Content.Client/GameObjects/Components/CloningPod/CloningPodWindow.cs index b09d600d57..066c5bc25a 100644 --- a/Content.Client/GameObjects/Components/CloningPod/CloningPodWindow.cs +++ b/Content.Client/GameObjects/Components/CloningPod/CloningPodWindow.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Diagnostics; using Robust.Client.UserInterface; @@ -5,6 +6,8 @@ using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.CustomControls; using Robust.Shared.Localization; using Robust.Shared.Maths; +using Robust.Shared.IoC; +using Robust.Shared.Timing; using static Content.Shared.GameObjects.Components.Medical.SharedCloningPodComponent; namespace Content.Client.GameObjects.Components.CloningPod @@ -59,7 +62,7 @@ namespace Content.Client.GameObjects.Components.CloningPod { MinSize = (200, 20), MinValue = 0, - MaxValue = 120, // todo make this actually derive from cloning time + MaxValue = 120, Page = 0, Value = 0.5f, Children = @@ -100,17 +103,37 @@ namespace Content.Client.GameObjects.Components.CloningPod { _scanManager = state.MindIdName; BuildCloneList(); - _lastUpdate = state; } + _lastUpdate = state; - var percentage = state.Progress / _cloningProgressBar.MaxValue * 100; - _progressLabel.Text = $"{percentage:0}%"; - - _cloningProgressBar.Value = state.Progress; + _cloningProgressBar.MaxValue = state.Maximum; + UpdateProgress(); _mindState.Text = Loc.GetString(state.MindPresent ? "Consciousness Detected" : "No Activity"); _mindState.FontColorOverride = state.MindPresent ? Color.Green : Color.Red; } + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + UpdateProgress(); + } + + private void UpdateProgress() + { + if (_lastUpdate == null) + return; + float simulatedProgress = _lastUpdate.Progress; + if (_lastUpdate.Progressing) + { + TimeSpan sinceReference = IoCManager.Resolve().CurTime - _lastUpdate.ReferenceTime; + simulatedProgress += (float) sinceReference.TotalSeconds; + simulatedProgress = MathHelper.Clamp(simulatedProgress, 0f, _lastUpdate.Maximum); + } + var percentage = simulatedProgress / _cloningProgressBar.MaxValue * 100; + _progressLabel.Text = $"{percentage:0}%"; + _cloningProgressBar.Value = simulatedProgress; + } + private void BuildCloneList() { _scanList.RemoveAllChildren(); diff --git a/Content.Server/GameObjects/Components/Medical/CloningPodComponent.cs b/Content.Server/GameObjects/Components/Medical/CloningPodComponent.cs index 84aff3a03a..c1d2feab29 100644 --- a/Content.Server/GameObjects/Components/Medical/CloningPodComponent.cs +++ b/Content.Server/GameObjects/Components/Medical/CloningPodComponent.cs @@ -4,17 +4,18 @@ using Content.Server.GameObjects.Components.Mobs; using Content.Server.GameObjects.Components.Observer; using Content.Server.GameObjects.Components.Power.ApcNetComponents; using Content.Server.GameObjects.EntitySystems; -using Content.Server.Interfaces; using Content.Server.Mobs; using Content.Server.Utility; using Content.Shared.GameObjects.Components.Medical; using Content.Shared.GameObjects.Components.Mobs.State; +using Content.Shared.Interfaces; using Content.Shared.Preferences; using Robust.Server.GameObjects; using Robust.Server.Player; using Robust.Shared.Containers; using Robust.Shared.GameObjects; using Robust.Shared.IoC; +using Robust.Shared.Localization; using Robust.Shared.Network; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.ViewVariables; @@ -24,7 +25,6 @@ namespace Content.Server.GameObjects.Components.Medical [RegisterComponent] public class CloningPodComponent : SharedCloningPodComponent { - [Dependency] private readonly IServerPreferencesManager _prefsManager = null!; [Dependency] private readonly IPlayerManager _playerManager = null!; [Dependency] private readonly EuiManager _euiManager = null!; @@ -40,6 +40,8 @@ namespace Content.Server.GameObjects.Components.Medical [ViewVariables] public float CloningProgress = 0; [DataField("cloningTime")] [ViewVariables] public float CloningTime = 30f; + // Used to prevent as many duplicate UI messages as possible + [ViewVariables] public bool UiKnownPowerState = false; [ViewVariables] public CloningPodStatus Status; @@ -56,8 +58,7 @@ namespace Content.Server.GameObjects.Components.Medical BodyContainer = ContainerHelpers.EnsureContainer(Owner, $"{Name}-bodyContainer"); //TODO: write this so that it checks for a change in power events for GORE POD cases - if (UserInterface != null) - EntitySystem.Get().UpdateUserInterface(this); + EntitySystem.Get().UpdateUserInterface(this); } public override void OnRemove() @@ -85,16 +86,28 @@ namespace Content.Server.GameObjects.Components.Medical switch (message.Button) { case UiButton.Clone: - if (message.ScanId == null || BodyContainer.ContainedEntity != null) + if (BodyContainer.ContainedEntity != null) + { + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("cloning-pod-component-msg-occupied")); return; + } + + if (message.ScanId == null) + { + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("cloning-pod-component-msg-no-selection")); + return; + } var cloningSystem = EntitySystem.Get(); - if (!cloningSystem.Minds.TryGetValue(message.ScanId.Value, out var mind)) + if (!cloningSystem.IdToDNA.TryGetValue(message.ScanId.Value, out var dna)) { + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("cloning-pod-component-msg-bad-selection")); return; // ScanId is not in database } + var mind = dna.Mind; + if (cloningSystem.ClonesWaitingForMind.TryGetValue(mind, out var cloneUid)) { if (Owner.EntityManager.TryGetEntity(cloneUid, out var clone) && @@ -102,7 +115,10 @@ namespace Content.Server.GameObjects.Components.Medical !cloneState.IsDead() && clone.TryGetComponent(out MindComponent? cloneMindComp) && (cloneMindComp.Mind == null || cloneMindComp.Mind == mind)) + { + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("cloning-pod-component-msg-already-cloning")); return; // Mind already has clone + } cloningSystem.ClonesWaitingForMind.Remove(mind); } @@ -110,17 +126,22 @@ namespace Content.Server.GameObjects.Components.Medical if (mind.OwnedEntity != null && mind.OwnedEntity.TryGetComponent(out var state) && !state.IsDead()) + { + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("cloning-pod-component-msg-already-alive")); return; // Body controlled by mind is not dead + } - // TODO: Implement ClonerDNAEntry and get the profile appearance and name when scanned + // Yes, we still need to track down the client because we need to open the Eui if (mind.UserId == null || !_playerManager.TryGetSessionById(mind.UserId.Value, out var client)) - return; + { + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("cloning-pod-component-msg-user-offline")); + return; // If we can't track down the client, we can't offer transfer. That'd be quite bad. + } var mob = Owner.EntityManager.SpawnEntity("HumanMob_Content", Owner.Transform.MapPosition); - var profile = GetPlayerProfileAsync(client.UserId); - mob.GetComponent().UpdateFromProfile(profile); - mob.Name = profile.Name; + mob.GetComponent().UpdateFromProfile(dna.Profile); + mob.Name = dna.Profile.Name; var cloneMindReturn = mob.AddComponent(); cloneMindReturn.Mind = mind; @@ -165,10 +186,5 @@ namespace Content.Server.GameObjects.Components.Medical UpdateAppearance(); EntitySystem.Get().UpdateUserInterface(this); } - - private HumanoidCharacterProfile GetPlayerProfileAsync(NetUserId userId) - { - return (HumanoidCharacterProfile) _prefsManager.GetPreferences(userId).SelectedCharacter; - } } } diff --git a/Content.Server/GameObjects/Components/Medical/MedicalScannerComponent.cs b/Content.Server/GameObjects/Components/Medical/MedicalScannerComponent.cs index ae2dfd3e08..e0ef37a86a 100644 --- a/Content.Server/GameObjects/Components/Medical/MedicalScannerComponent.cs +++ b/Content.Server/GameObjects/Components/Medical/MedicalScannerComponent.cs @@ -1,10 +1,11 @@ -#nullable enable +#nullable enable using System; using System.Collections.Generic; using Content.Server.GameObjects.Components.Mobs; using Content.Server.GameObjects.Components.Power.ApcNetComponents; using Content.Server.GameObjects.EntitySystems; using Content.Server.Utility; +using Content.Server.Interfaces; using Content.Shared.Damage; using Content.Shared.GameObjects.Components.Damage; using Content.Shared.GameObjects.Components.Medical; @@ -12,13 +13,17 @@ using Content.Shared.GameObjects.Components.Mobs.State; using Content.Shared.GameObjects.EntitySystems; using Content.Shared.GameObjects.EntitySystems.ActionBlocker; using Content.Shared.GameObjects.Verbs; +using Content.Shared.Interfaces; using Content.Shared.Interfaces.GameObjects.Components; +using Content.Shared.Preferences; using Robust.Server.GameObjects; +using Robust.Server.Player; using Robust.Shared.Containers; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Maths; +using Robust.Shared.Network; using Robust.Shared.Timing; using Robust.Shared.ViewVariables; @@ -29,6 +34,8 @@ namespace Content.Server.GameObjects.Components.Medical [ComponentReference(typeof(SharedMedicalScannerComponent))] public class MedicalScannerComponent : SharedMedicalScannerComponent, IActivate, IDestroyAct { + [Dependency] private readonly IServerPreferencesManager _prefsManager = null!; + [Dependency] private readonly IPlayerManager _playerManager = null!; [Dependency] private readonly IGameTiming _gameTiming = default!; private static readonly TimeSpan InternalOpenAttemptDelay = TimeSpan.FromSeconds(0.5); @@ -269,13 +276,32 @@ namespace Content.Server.GameObjects.Components.Medical case UiButton.ScanDNA: if (_bodyContainer.ContainedEntity != null) { - //TODO: Show a 'ERROR: Body is completely devoid of soul' if no Mind owns the entity. var cloningSystem = EntitySystem.Get(); - if (!_bodyContainer.ContainedEntity.TryGetComponent(out MindComponent? mind) || mind.Mind == null) + if (!_bodyContainer.ContainedEntity.TryGetComponent(out MindComponent? mindComp) || mindComp.Mind == null) + { + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("medical-scanner-component-msg-no-soul")); break; + } - cloningSystem.AddToDnaScans(mind.Mind); + // Null suppression based on above check. Yes, it's explicitly needed + var mind = mindComp.Mind!; + + // We need the HumanoidCharacterProfile + // TODO: Move this further 'outwards' into a DNAComponent or somesuch. + // Ideally this ends with GameTicker & CloningSystem handing DNA to a function that sets up a body for that DNA. + var mindUser = mind.UserId; + + if (mindUser == null) + { + // For now assume this means soul departed + obj.Session.AttachedEntity?.PopupMessageCursor(Loc.GetString("medical-scanner-component-msg-soul-broken")); + break; + } + + // has to be explicit cast like this, IDK why, null suppression operators seem to not work + var profile = GetPlayerProfileAsync((NetUserId) mindUser); + cloningSystem.AddToDnaScans(new ClonerDNAEntry(mind, profile)); } break; @@ -294,5 +320,10 @@ namespace Content.Server.GameObjects.Components.Medical { EjectBody(); } + + private HumanoidCharacterProfile GetPlayerProfileAsync(NetUserId userId) + { + return (HumanoidCharacterProfile) _prefsManager.GetPreferences(userId).SelectedCharacter; + } } } diff --git a/Content.Server/GameObjects/EntitySystems/CloningSystem.cs b/Content.Server/GameObjects/EntitySystems/CloningSystem.cs index 467eea5c10..f192aecdac 100644 --- a/Content.Server/GameObjects/EntitySystems/CloningSystem.cs +++ b/Content.Server/GameObjects/EntitySystems/CloningSystem.cs @@ -1,3 +1,4 @@ +#nullable enable using System.Collections.Generic; using System.Linq; using Content.Server.GameObjects.Components.Medical; @@ -6,16 +7,23 @@ using Content.Server.GameObjects.Components.Power.ApcNetComponents; using Content.Server.Mobs; using Content.Shared.GameTicking; using Content.Shared.Interfaces.GameObjects.Components; +using Content.Shared.Preferences; using Robust.Server.GameObjects; using Robust.Shared.GameObjects; using Robust.Shared.Maths; +using Robust.Shared.Timing; +using Robust.Shared.IoC; using static Content.Shared.GameObjects.Components.Medical.SharedCloningPodComponent; namespace Content.Server.GameObjects.EntitySystems { internal sealed class CloningSystem : EntitySystem, IResettingEntitySystem { - public readonly Dictionary Minds = new(); + [Dependency] private readonly IGameTiming _timing = default!; + public readonly Dictionary MindToId = new(); + public readonly Dictionary IdToDNA = new(); + private int _nextAllocatedMindId = 0; + private float _quickAndDirtyUserUpdatePreventerTimer = 0.0f; public readonly Dictionary ClonesWaitingForMind = new(); public override void Initialize() @@ -42,8 +50,9 @@ namespace Content.Server.GameObjects.EntitySystems mindComp.Mind != null) return; - mind?.TransferTo(entity); - mind?.UnVisit(); + mind.TransferTo(entity); + mind.UnVisit(); + ClonesWaitingForMind.Remove(mind); } private void HandleActivate(EntityUid uid, CloningPodComponent component, ActivateInWorldEvent args) @@ -75,6 +84,13 @@ namespace Content.Server.GameObjects.EntitySystems { foreach (var (cloning, power) in ComponentManager.EntityQuery(true)) { + if (cloning.UiKnownPowerState != power.Powered) + { + // Must be *before* update + cloning.UiKnownPowerState = power.Powered; + UpdateUserInterface(cloning); + } + if (!power.Powered) return; @@ -88,8 +104,6 @@ namespace Content.Server.GameObjects.EntitySystems { cloning.Eject(); } - - UpdateUserInterface(cloning); } } @@ -99,32 +113,65 @@ namespace Content.Server.GameObjects.EntitySystems comp.UserInterface?.SetState( new CloningPodBoundUserInterfaceState( idToUser, + // now + _timing.CurTime, + // progress, time, progressing comp.CloningProgress, + comp.CloningTime, + // this is duplicate w/ the above check that actually updates progress + // better here than on client though + comp.UiKnownPowerState && (comp.BodyContainer.ContainedEntity != null), comp.Status == CloningPodStatus.Cloning)); } - public void AddToDnaScans(Mind mind) + public void AddToDnaScans(ClonerDNAEntry dna) { - if (!Minds.ContainsValue(mind)) + if (!MindToId.ContainsKey(dna.Mind)) { - Minds.Add(Minds.Count, mind); + int id = _nextAllocatedMindId++; + MindToId.Add(dna.Mind, id); + IdToDNA.Add(id, dna); } + OnChangeMadeToDnaScans(); + } + + public void OnChangeMadeToDnaScans() + { + foreach (var cloning in ComponentManager.EntityQuery(true)) + UpdateUserInterface(cloning); } public bool HasDnaScan(Mind mind) { - return Minds.ContainsValue(mind); + return MindToId.ContainsKey(mind); } public Dictionary GetIdToUser() { - return Minds.ToDictionary(m => m.Key, m => m.Value.CharacterName); + return IdToDNA.ToDictionary(m => m.Key, m => m.Value.Mind.CharacterName); } public void Reset() { - Minds.Clear(); + MindToId.Clear(); + IdToDNA.Clear(); ClonesWaitingForMind.Clear(); + _nextAllocatedMindId = 0; + // We PROBABLY don't need to send out UI interface updates for the dna scan changes during a reset + } + } + + // TODO: This needs to be moved to Content.Server.Mobs and made a global point of reference. + // For example, GameTicker should be using this, and this should be using ICharacterProfile rather than HumanoidCharacterProfile. + // It should carry a reference or copy of itself with the mobs that it affects. + // See TODO in MedicalScannerComponent. + struct ClonerDNAEntry { + public Mind Mind; + public HumanoidCharacterProfile Profile; + public ClonerDNAEntry(Mind m, HumanoidCharacterProfile hcp) + { + Mind = m; + Profile = hcp; } } } diff --git a/Content.Shared/GameObjects/Components/Medical/SharedCloningPodComponent.cs b/Content.Shared/GameObjects/Components/Medical/SharedCloningPodComponent.cs index 0139e7f554..75d2b467d2 100644 --- a/Content.Shared/GameObjects/Components/Medical/SharedCloningPodComponent.cs +++ b/Content.Shared/GameObjects/Components/Medical/SharedCloningPodComponent.cs @@ -14,13 +14,25 @@ namespace Content.Shared.GameObjects.Components.Medical public class CloningPodBoundUserInterfaceState : BoundUserInterfaceState { public readonly Dictionary MindIdName; + // When this state was created. + // The reason this is used rather than a start time is because cloning can be interrupted. + public readonly TimeSpan ReferenceTime; + // Both of these are in seconds. + // They're not TimeSpans because of complicated reasons. + // CurTime of receipt is combined with Progress. public readonly float Progress; + public readonly float Maximum; + // If true, cloning is progressing (predict clone progress) + public readonly bool Progressing; public readonly bool MindPresent; - public CloningPodBoundUserInterfaceState(Dictionary mindIdName, float progress, bool mindPresent) + public CloningPodBoundUserInterfaceState(Dictionary mindIdName, TimeSpan refTime, float progress, float maximum, bool progressing, bool mindPresent) { MindIdName = mindIdName; + ReferenceTime = refTime; Progress = progress; + Maximum = maximum; + Progressing = progressing; MindPresent = mindPresent; } } diff --git a/Resources/Locale/en-US/entities/cloning.ftl b/Resources/Locale/en-US/entities/cloning.ftl new file mode 100644 index 0000000000..940d4a76a3 --- /dev/null +++ b/Resources/Locale/en-US/entities/cloning.ftl @@ -0,0 +1,11 @@ +### yup etc + +cloning-pod-component-msg-occupied = ERROR: The pod already contains a clone +cloning-pod-component-msg-no-selection = ERROR: You didn't select someone to clone +cloning-pod-component-msg-bad-selection = ERROR: Entry Removed During Selection // System Error +cloning-pod-component-msg-already-cloning = ERROR: Pod Network Conflict +cloning-pod-component-msg-already-alive = ERROR: Metaphysical Conflict +cloning-pod-component-msg-user-offline = ERROR: Metaphysical Disturbance +medical-scanner-component-msg-no-soul = ERROR: Body is completely devoid of soul +medical-scanner-component-msg-soul-broken = ERROR: Soul present, but defunct / departed +