From e624317d37b7196705ac6b02b7d98f036285f71e Mon Sep 17 00:00:00 2001 From: Nemanja <98561806+EmoGarbage404@users.noreply.github.com> Date: Mon, 15 Jan 2024 01:35:28 -0500 Subject: [PATCH] Cryogenic Sleep Units (#24096) * Cryogenic sleep units * pause map support * no more body deletion * Cryogenic Storage Units * boowomp * no more emag, no more dropping present people (cherry picked from commit 736b9dd7df6e35f07fed88686c7c863ac61af846) --- .../CryostorageBoundUserInterface.cs | 56 ++++ .../Cryostorage/CryostorageEntryControl.xaml | 21 ++ .../CryostorageEntryControl.xaml.cs | 46 +++ .../Bed/Cryostorage/CryostorageMenu.xaml | 33 ++ .../Bed/Cryostorage/CryostorageMenu.xaml.cs | 54 +++ .../Cryostorage/CryostorageSlotControl.xaml | 13 + .../CryostorageSlotControl.xaml.cs | 18 + .../Bed/Cryostorage/CryostorageSystem.cs | 9 + .../Bed/Cryostorage/CryostorageSystem.cs | 309 ++++++++++++++++++ .../GameTicking/GameTicker.Spawning.cs | 2 +- .../ContainerSpawnPointComponent.cs | 30 ++ .../ContainerSpawnPointSystem.cs | 85 +++++ .../Components/StationJobsComponent.cs | 9 + .../Station/Systems/StationJobsSystem.cs | 44 ++- .../Components/AccessReaderComponent.cs | 6 + .../Components/IdCardConsoleComponent.cs | 1 + .../Access/Systems/AccessReaderSystem.cs | 2 + .../Bed/Cryostorage/CryostorageComponent.cs | 110 +++++++ .../CryostorageContainedComponent.cs | 34 ++ .../Cryostorage/SharedCryostorageSystem.cs | 179 ++++++++++ Content.Shared/CCVar/CCVars.cs | 6 + .../Climbing/Systems/ClimbSystem.cs | 2 +- .../DragInsertContainerComponent.cs | 20 ++ .../Containers/DragInsertContainerSystem.cs | 120 +++++++ .../ExitContainerOnMoveComponent.cs | 14 + .../Containers/ExitContainerOnMoveSystem.cs | 31 ++ .../Inventory/InventorySystem.Slots.cs | 2 +- .../Locale/en-US/containers/containers.ftl | 2 + .../en-US/prototypes/access/accesses.ftl | 1 + .../Locale/en-US/round-end/cryostorage.ftl | 10 + Resources/Prototypes/Access/command.yml | 5 + Resources/Prototypes/Access/misc.yml | 1 + Resources/Prototypes/Access/security.yml | 1 + .../Entities/Structures/cryopod.yml | 62 ++++ .../Structures/cryostorage.rsi/meta.json | 45 +++ .../Structures/cryostorage.rsi/sleeper_0.png | Bin 0 -> 3110 bytes .../Structures/cryostorage.rsi/sleeper_1.png | Bin 0 -> 6649 bytes SpaceStation14.sln.DotSettings | 1 + 38 files changed, 1376 insertions(+), 8 deletions(-) create mode 100644 Content.Client/Bed/Cryostorage/CryostorageBoundUserInterface.cs create mode 100644 Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml create mode 100644 Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml.cs create mode 100644 Content.Client/Bed/Cryostorage/CryostorageMenu.xaml create mode 100644 Content.Client/Bed/Cryostorage/CryostorageMenu.xaml.cs create mode 100644 Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml create mode 100644 Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml.cs create mode 100644 Content.Client/Bed/Cryostorage/CryostorageSystem.cs create mode 100644 Content.Server/Bed/Cryostorage/CryostorageSystem.cs create mode 100644 Content.Server/Spawners/Components/ContainerSpawnPointComponent.cs create mode 100644 Content.Server/Spawners/EntitySystems/ContainerSpawnPointSystem.cs create mode 100644 Content.Shared/Bed/Cryostorage/CryostorageComponent.cs create mode 100644 Content.Shared/Bed/Cryostorage/CryostorageContainedComponent.cs create mode 100644 Content.Shared/Bed/Cryostorage/SharedCryostorageSystem.cs create mode 100644 Content.Shared/Containers/DragInsertContainerComponent.cs create mode 100644 Content.Shared/Containers/DragInsertContainerSystem.cs create mode 100644 Content.Shared/Containers/ExitContainerOnMoveComponent.cs create mode 100644 Content.Shared/Containers/ExitContainerOnMoveSystem.cs create mode 100644 Resources/Locale/en-US/containers/containers.ftl create mode 100644 Resources/Locale/en-US/round-end/cryostorage.ftl create mode 100644 Resources/Prototypes/Entities/Structures/cryopod.yml create mode 100644 Resources/Textures/Structures/cryostorage.rsi/meta.json create mode 100644 Resources/Textures/Structures/cryostorage.rsi/sleeper_0.png create mode 100644 Resources/Textures/Structures/cryostorage.rsi/sleeper_1.png diff --git a/Content.Client/Bed/Cryostorage/CryostorageBoundUserInterface.cs b/Content.Client/Bed/Cryostorage/CryostorageBoundUserInterface.cs new file mode 100644 index 0000000000..ffab162548 --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageBoundUserInterface.cs @@ -0,0 +1,56 @@ +using Content.Shared.Bed.Cryostorage; +using JetBrains.Annotations; + +namespace Content.Client.Bed.Cryostorage; + +[UsedImplicitly] +public sealed class CryostorageBoundUserInterface : BoundUserInterface +{ + [ViewVariables] + private CryostorageMenu? _menu; + + public CryostorageBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + + _menu = new(); + + _menu.OnClose += Close; + + _menu.SlotRemoveButtonPressed += (ent, slot) => + { + SendMessage(new CryostorageRemoveItemBuiMessage(ent, slot, CryostorageRemoveItemBuiMessage.RemovalType.Inventory)); + }; + + _menu.HandRemoveButtonPressed += (ent, hand) => + { + SendMessage(new CryostorageRemoveItemBuiMessage(ent, hand, CryostorageRemoveItemBuiMessage.RemovalType.Hand)); + }; + + _menu.OpenCentered(); + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + switch (state) + { + case CryostorageBuiState msg: + _menu?.UpdateState(msg); + break; + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (!disposing) + return; + _menu?.Dispose(); + } +} diff --git a/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml new file mode 100644 index 0000000000..176acbf29b --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml.cs b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml.cs new file mode 100644 index 0000000000..09e418fd86 --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageEntryControl.xaml.cs @@ -0,0 +1,46 @@ +using Content.Shared.Bed.Cryostorage; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.Bed.Cryostorage; + +[GenerateTypedNameReferences] +public sealed partial class CryostorageEntryControl : BoxContainer +{ + public event Action? SlotRemoveButtonPressed; + public event Action? HandRemoveButtonPressed; + + public NetEntity Entity; + public bool LastOpenState; + + public CryostorageEntryControl(CryostorageContainedPlayerData data) + { + RobustXamlLoader.Load(this); + Entity = data.PlayerEnt; + Update(data); + } + + public void Update(CryostorageContainedPlayerData data) + { + LastOpenState = Collapsible.BodyVisible; + Heading.Title = data.PlayerName; + Body.Visible = data.ItemSlots.Count != 0 && data.HeldItems.Count != 0; + + ItemsContainer.Children.Clear(); + foreach (var (name, itemName) in data.ItemSlots) + { + var control = new CryostorageSlotControl(name, itemName); + control.Button.OnPressed += _ => SlotRemoveButtonPressed?.Invoke(name); + ItemsContainer.AddChild(control); + } + + foreach (var (name, held) in data.HeldItems) + { + var control = new CryostorageSlotControl(Loc.GetString("cryostorage-ui-filler-hand"), held); + control.Button.OnPressed += _ => HandRemoveButtonPressed?.Invoke(name); + ItemsContainer.AddChild(control); + } + Collapsible.BodyVisible = LastOpenState; + } +} diff --git a/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml new file mode 100644 index 0000000000..5360cdb38e --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + diff --git a/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml.cs b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml.cs new file mode 100644 index 0000000000..51f1561939 --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageMenu.xaml.cs @@ -0,0 +1,54 @@ +using System.Linq; +using Content.Client.UserInterface.Controls; +using Content.Shared.Bed.Cryostorage; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Collections; +using Robust.Shared.Utility; + +namespace Content.Client.Bed.Cryostorage; + +[GenerateTypedNameReferences] +public sealed partial class CryostorageMenu : FancyWindow +{ + public event Action? SlotRemoveButtonPressed; + public event Action? HandRemoveButtonPressed; + + public CryostorageMenu() + { + RobustXamlLoader.Load(this); + } + + public void UpdateState(CryostorageBuiState state) + { + var data = state.PlayerData; + var nonexistentEntries = new ValueList(data); + + var children = new ValueList(EntriesContainer.Children); + foreach (var control in children) + { + if (control is not CryostorageEntryControl entryControl) + continue; + + if (data.Where(p => p.PlayerEnt == entryControl.Entity).FirstOrNull() is not { } datum) + { + EntriesContainer.Children.Remove(entryControl); + continue; + } + + nonexistentEntries.Remove(datum); + entryControl.Update(datum); + } + + foreach (var player in nonexistentEntries) + { + var control = new CryostorageEntryControl(player); + control.SlotRemoveButtonPressed += a => SlotRemoveButtonPressed?.Invoke(player.PlayerEnt, a); + control.HandRemoveButtonPressed += a => HandRemoveButtonPressed?.Invoke(player.PlayerEnt, a); + EntriesContainer.Children.Add(control); + } + + EmptyLabel.Visible = data.Count == 0; + } +} diff --git a/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml new file mode 100644 index 0000000000..b45e77cd1a --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml @@ -0,0 +1,13 @@ + + + + + + diff --git a/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml.cs b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml.cs new file mode 100644 index 0000000000..629b958262 --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageSlotControl.xaml.cs @@ -0,0 +1,18 @@ +using Content.Client.Message; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.Bed.Cryostorage; + +[GenerateTypedNameReferences] +public sealed partial class CryostorageSlotControl : BoxContainer +{ + public CryostorageSlotControl(string name, string itemName) + { + RobustXamlLoader.Load(this); + + SlotLabel.SetMarkup(Loc.GetString("cryostorage-ui-label-slot-name", ("slot", name))); + ItemLabel.Text = itemName; + } +} diff --git a/Content.Client/Bed/Cryostorage/CryostorageSystem.cs b/Content.Client/Bed/Cryostorage/CryostorageSystem.cs new file mode 100644 index 0000000000..882f433841 --- /dev/null +++ b/Content.Client/Bed/Cryostorage/CryostorageSystem.cs @@ -0,0 +1,9 @@ +using Content.Shared.Bed.Cryostorage; + +namespace Content.Client.Bed.Cryostorage; + +/// +public sealed class CryostorageSystem : SharedCryostorageSystem +{ + +} diff --git a/Content.Server/Bed/Cryostorage/CryostorageSystem.cs b/Content.Server/Bed/Cryostorage/CryostorageSystem.cs new file mode 100644 index 0000000000..799bb82eff --- /dev/null +++ b/Content.Server/Bed/Cryostorage/CryostorageSystem.cs @@ -0,0 +1,309 @@ +using System.Linq; +using Content.Server.Chat.Managers; +using Content.Server.GameTicking; +using Content.Server.Hands.Systems; +using Content.Server.Inventory; +using Content.Server.Popups; +using Content.Server.Station.Components; +using Content.Server.Station.Systems; +using Content.Server.UserInterface; +using Content.Shared.Access.Systems; +using Content.Shared.Bed.Cryostorage; +using Content.Shared.Chat; +using Content.Shared.Climbing.Systems; +using Content.Shared.Database; +using Content.Shared.Hands.Components; +using Content.Shared.Mind.Components; +using Robust.Server.Audio; +using Robust.Server.Containers; +using Robust.Server.GameObjects; +using Robust.Server.Player; +using Robust.Shared.Containers; +using Robust.Shared.Enums; +using Robust.Shared.Network; +using Robust.Shared.Player; + +namespace Content.Server.Bed.Cryostorage; + +/// +public sealed class CryostorageSystem : SharedCryostorageSystem +{ + [Dependency] private readonly IChatManager _chatManager = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly AudioSystem _audio = default!; + [Dependency] private readonly AccessReaderSystem _accessReader = default!; + [Dependency] private readonly ClimbSystem _climb = default!; + [Dependency] private readonly ContainerSystem _container = default!; + [Dependency] private readonly GameTicker _gameTicker = default!; + [Dependency] private readonly HandsSystem _hands = default!; + [Dependency] private readonly ServerInventorySystem _inventory = default!; + [Dependency] private readonly PopupSystem _popup = default!; + [Dependency] private readonly StationSystem _station = default!; + [Dependency] private readonly StationJobsSystem _stationJobs = default!; + [Dependency] private readonly TransformSystem _transform = default!; + [Dependency] private readonly UserInterfaceSystem _ui = default!; + + /// + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnBeforeUIOpened); + SubscribeLocalEvent(OnRemoveItemBuiMessage); + + SubscribeLocalEvent(OnPlayerSpawned); + SubscribeLocalEvent(OnMindRemoved); + + _playerManager.PlayerStatusChanged += PlayerStatusChanged; + } + + public override void Shutdown() + { + base.Shutdown(); + + _playerManager.PlayerStatusChanged -= PlayerStatusChanged; + } + + private void OnBeforeUIOpened(Entity ent, ref BeforeActivatableUIOpenEvent args) + { + UpdateCryostorageUIState(ent); + } + + private void OnRemoveItemBuiMessage(Entity ent, ref CryostorageRemoveItemBuiMessage args) + { + var comp = ent.Comp; + if (args.Session.AttachedEntity is not { } attachedEntity) + return; + + var cryoContained = GetEntity(args.Entity); + + if (!comp.StoredPlayers.Contains(cryoContained)) + return; + + if (!HasComp(attachedEntity)) + return; + + if (!_accessReader.IsAllowed(attachedEntity, ent)) + { + _popup.PopupEntity(Loc.GetString("cryostorage-popup-access-denied"), attachedEntity, attachedEntity); + return; + } + + EntityUid? entity = null; + if (args.Type == CryostorageRemoveItemBuiMessage.RemovalType.Hand) + { + if (_hands.TryGetHand(cryoContained, args.Key, out var hand)) + entity = hand.HeldEntity; + } + else + { + if (_inventory.TryGetSlotContainer(cryoContained, args.Key, out var slot, out _)) + entity = slot.ContainedEntity; + } + + if (entity == null) + return; + + AdminLog.Add(LogType.Action, LogImpact.High, + $"{ToPrettyString(attachedEntity):player} removed item {ToPrettyString(entity)} from cryostorage-contained player " + + $"{ToPrettyString(cryoContained):player}, stored in cryostorage {ToPrettyString(ent)}"); + _container.TryRemoveFromContainer(entity.Value); + _transform.SetCoordinates(entity.Value, Transform(attachedEntity).Coordinates); + _hands.PickupOrDrop(attachedEntity, entity.Value); + UpdateCryostorageUIState(ent); + } + + private void UpdateCryostorageUIState(Entity ent) + { + var state = new CryostorageBuiState(GetAllContainedData(ent).ToList()); + _ui.TrySetUiState(ent, CryostorageUIKey.Key, state); + } + + private void OnPlayerSpawned(Entity ent, ref PlayerSpawnCompleteEvent args) + { + // if you spawned into cryostorage, we're not gonna round-remove you. + ent.Comp.GracePeriodEndTime = null; + } + + private void OnMindRemoved(Entity ent, ref MindRemovedMessage args) + { + var comp = ent.Comp; + + if (!TryComp(comp.Cryostorage, out var cryostorageComponent)) + return; + + if (comp.GracePeriodEndTime != null) + comp.GracePeriodEndTime = Timing.CurTime + cryostorageComponent.NoMindGracePeriod; + comp.UserId = args.Mind.Comp.UserId; + } + + private void PlayerStatusChanged(object? sender, SessionStatusEventArgs args) + { + if (args.Session.AttachedEntity is not { } entity) + return; + + if (!TryComp(entity, out var containedComponent)) + return; + + if (args.NewStatus is SessionStatus.Disconnected or SessionStatus.Zombie) + { + if (CryoSleepRejoiningEnabled) + containedComponent.StoredWhileDisconnected = true; + + var delay = CompOrNull(containedComponent.Cryostorage)?.NoMindGracePeriod ?? TimeSpan.Zero; + containedComponent.GracePeriodEndTime = Timing.CurTime + delay; + containedComponent.UserId = args.Session.UserId; + } + else if (args.NewStatus == SessionStatus.InGame) + { + HandleCryostorageReconnection((entity, containedComponent)); + } + } + + public void HandleEnterCryostorage(Entity ent, NetUserId? userId) + { + var comp = ent.Comp; + var cryostorageEnt = ent.Comp.Cryostorage; + if (!TryComp(cryostorageEnt, out var cryostorageComponent)) + return; + + // if we have a session, we use that to add back in all the job slots the player had. + if (userId != null) + { + foreach (var station in _station.GetStationsSet()) + { + if (!TryComp(station, out var stationJobs)) + continue; + + if (!_stationJobs.TryGetPlayerJobs(station, userId.Value, out var jobs, stationJobs)) + continue; + + foreach (var job in jobs) + { + _stationJobs.TryAdjustJobSlot(station, job, 1, clamp: true); + } + + _stationJobs.TryRemovePlayerJobs(station, userId.Value, stationJobs); + } + } + + _audio.PlayPvs(cryostorageComponent.RemoveSound, ent); + + EnsurePausedMap(); + if (PausedMap == null) + { + Log.Error("CryoSleep map was unexpectedly null"); + return; + } + + if (!comp.StoredWhileDisconnected && + userId != null && + Mind.TryGetMind(userId.Value, out var mind) && + mind.Value.Comp.Session?.AttachedEntity == ent) + { + _gameTicker.OnGhostAttempt(mind.Value, false); + } + _transform.SetParent(ent, PausedMap.Value); + cryostorageComponent.StoredPlayers.Add(ent); + UpdateCryostorageUIState((cryostorageEnt.Value, cryostorageComponent)); + AdminLog.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(ent):player} was entered into cryostorage inside of {ToPrettyString(cryostorageEnt.Value)}"); + } + + private void HandleCryostorageReconnection(Entity entity) + { + var (uid, comp) = entity; + if (!CryoSleepRejoiningEnabled || !comp.StoredWhileDisconnected) + return; + + // how did you destroy these? they're indestructible. + if (comp.Cryostorage is not { } cryostorage || + TerminatingOrDeleted(cryostorage) || + !TryComp(comp.Cryostorage, out var cryostorageComponent)) + { + QueueDel(entity); + return; + } + + var cryoXform = Transform(cryostorage); + _transform.SetParent(uid, cryoXform.ParentUid); + _transform.SetCoordinates(uid, cryoXform.Coordinates); + if (!_container.TryGetContainer(cryostorage, cryostorageComponent.ContainerId, out var container) || + !_container.Insert(uid, container, cryoXform)) + { + _climb.ForciblySetClimbing(uid, cryostorage); + } + + comp.GracePeriodEndTime = null; + comp.StoredWhileDisconnected = false; + cryostorageComponent.StoredPlayers.Remove(entity); + AdminLog.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(entity):player} re-entered the game from cryostorage {ToPrettyString(cryostorage)}"); + UpdateCryostorageUIState((cryostorage, cryostorageComponent)); + } + + protected override void OnInsertedContainer(Entity ent, ref EntInsertedIntoContainerMessage args) + { + var (uid, comp) = ent; + if (args.Container.ID != comp.ContainerId) + return; + + base.OnInsertedContainer(ent, ref args); + + var locKey = CryoSleepRejoiningEnabled + ? "cryostorage-insert-message-temp" + : "cryostorage-insert-message-permanent"; + + var msg = Loc.GetString(locKey, ("time", comp.GracePeriod.TotalMinutes)); + if (TryComp(args.Entity, out var actor)) + _chatManager.ChatMessageToOne(ChatChannel.Server, msg, msg, uid, false, actor.PlayerSession.Channel); + } + + private IEnumerable GetAllContainedData(Entity ent) + { + foreach (var contained in ent.Comp.StoredPlayers) + { + yield return GetContainedData(contained); + } + } + + private CryostorageContainedPlayerData GetContainedData(EntityUid uid) + { + var data = new CryostorageContainedPlayerData(); + data.PlayerName = Name(uid); + data.PlayerEnt = GetNetEntity(uid); + + var enumerator = _inventory.GetSlotEnumerator(uid); + while (enumerator.NextItem(out var item, out var slotDef)) + { + data.ItemSlots.Add(slotDef.Name, Name(item)); + } + + foreach (var hand in _hands.EnumerateHands(uid)) + { + if (hand.HeldEntity == null) + continue; + + data.HeldItems.Add(hand.Name, Name(hand.HeldEntity.Value)); + } + + return data; + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var containedComp)) + { + if (containedComp.GracePeriodEndTime == null || containedComp.StoredWhileDisconnected) + continue; + + if (Timing.CurTime < containedComp.GracePeriodEndTime) + continue; + + Mind.TryGetMind(uid, out _, out var mindComp); + var id = mindComp?.UserId ?? containedComp.UserId; + HandleEnterCryostorage((uid, containedComp), id); + } + } +} diff --git a/Content.Server/GameTicking/GameTicker.Spawning.cs b/Content.Server/GameTicking/GameTicker.Spawning.cs index 9bf40b20d4..af2c5b5e79 100644 --- a/Content.Server/GameTicking/GameTicker.Spawning.cs +++ b/Content.Server/GameTicking/GameTicker.Spawning.cs @@ -239,7 +239,7 @@ private void SpawnPlayer(ICommonSession player, HumanoidCharacterProfile charact EntityManager.AddComponent(mob); } - _stationJobs.TryAssignJob(station, jobPrototype); + _stationJobs.TryAssignJob(station, jobPrototype, player.UserId); if (lateJoin) _adminLogger.Add(LogType.LateJoin, LogImpact.Medium, $"Player {player.Name} late joined as {character.Name:characterName} on station {Name(station):stationName} with {ToPrettyString(mob):entity} as a {jobName:jobName}."); diff --git a/Content.Server/Spawners/Components/ContainerSpawnPointComponent.cs b/Content.Server/Spawners/Components/ContainerSpawnPointComponent.cs new file mode 100644 index 0000000000..5cd2ac3048 --- /dev/null +++ b/Content.Server/Spawners/Components/ContainerSpawnPointComponent.cs @@ -0,0 +1,30 @@ +using Content.Server.Spawners.EntitySystems; + +namespace Content.Server.Spawners.Components; + +/// +/// A spawn point that spawns a player into a target container rather than simply spawning them at a position. +/// Occurs before regular spawn points but after arrivals. +/// +[RegisterComponent] +[Access(typeof(ContainerSpawnPointSystem))] +public sealed partial class ContainerSpawnPointComponent : Component +{ + /// + /// The ID of the container that this entity will spawn players into + /// + [DataField, ViewVariables(VVAccess.ReadWrite)] + public string ContainerId; + + /// + /// An optional job specifier + /// + [DataField, ViewVariables(VVAccess.ReadWrite)] + public string? Job; + + /// + /// The type of spawn point + /// + [DataField, ViewVariables(VVAccess.ReadWrite)] + public SpawnPointType SpawnType = SpawnPointType.Unset; +} diff --git a/Content.Server/Spawners/EntitySystems/ContainerSpawnPointSystem.cs b/Content.Server/Spawners/EntitySystems/ContainerSpawnPointSystem.cs new file mode 100644 index 0000000000..65f1076700 --- /dev/null +++ b/Content.Server/Spawners/EntitySystems/ContainerSpawnPointSystem.cs @@ -0,0 +1,85 @@ +using Content.Server.GameTicking; +using Content.Server.Shuttles.Systems; +using Content.Server.Spawners.Components; +using Content.Server.Station.Systems; +using Robust.Server.Containers; +using Robust.Shared.Containers; +using Robust.Shared.Random; + +namespace Content.Server.Spawners.EntitySystems; + +public sealed class ContainerSpawnPointSystem : EntitySystem +{ + [Dependency] private readonly GameTicker _gameTicker = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly ContainerSystem _container = default!; + [Dependency] private readonly StationSystem _station = default!; + [Dependency] private readonly StationSpawningSystem _stationSpawning = default!; + + public override void Initialize() + { + SubscribeLocalEvent(OnSpawnPlayer, before: new[] { typeof(SpawnPointSystem), typeof(ArrivalsSystem) }); + } + + private void OnSpawnPlayer(PlayerSpawningEvent args) + { + if (args.SpawnResult != null) + return; + + var query = EntityQueryEnumerator(); + var possibleContainers = new List>(); + + while (query.MoveNext(out var uid, out var spawnPoint, out var container, out var xform)) + { + if (args.Station != null && _station.GetOwningStation(uid, xform) != args.Station) + continue; + + // If it's unset, then we allow it to be used for both roundstart and midround joins + if (spawnPoint.SpawnType == SpawnPointType.Unset) + { + // make sure we also check the job here for various reasons. + if (spawnPoint.Job == null || spawnPoint.Job == args.Job?.Prototype) + possibleContainers.Add((uid, spawnPoint, container, xform)); + continue; + } + + if (_gameTicker.RunLevel == GameRunLevel.InRound && spawnPoint.SpawnType == SpawnPointType.LateJoin) + { + possibleContainers.Add((uid, spawnPoint, container, xform)); + } + + if (_gameTicker.RunLevel != GameRunLevel.InRound && + spawnPoint.SpawnType == SpawnPointType.Job && + (args.Job == null || spawnPoint.Job == args.Job.Prototype)) + { + possibleContainers.Add((uid, spawnPoint, container, xform)); + } + } + + if (possibleContainers.Count == 0) + return; + // we just need some default coords so we can spawn the player entity. + var baseCoords = possibleContainers[0].Comp3.Coordinates; + + args.SpawnResult = _stationSpawning.SpawnPlayerMob( + baseCoords, + args.Job, + args.HumanoidCharacterProfile, + args.Station); + + _random.Shuffle(possibleContainers); + foreach (var (uid, spawnPoint, manager, xform) in possibleContainers) + { + if (!_container.TryGetContainer(uid, spawnPoint.ContainerId, out var container, manager)) + continue; + + if (!_container.Insert(args.SpawnResult.Value, container, containerXform: xform)) + continue; + + return; + } + + Del(args.Station); + args.SpawnResult = null; + } +} diff --git a/Content.Server/Station/Components/StationJobsComponent.cs b/Content.Server/Station/Components/StationJobsComponent.cs index 677600df7e..74399bf412 100644 --- a/Content.Server/Station/Components/StationJobsComponent.cs +++ b/Content.Server/Station/Components/StationJobsComponent.cs @@ -1,6 +1,8 @@ using Content.Server.Station.Systems; using Content.Shared.Roles; using JetBrains.Annotations; +using Robust.Shared.Network; +using Robust.Shared.Prototypes; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; @@ -75,6 +77,13 @@ public sealed partial class StationJobsComponent : Component [DataField("overflowJobs", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))] public HashSet OverflowJobs = new(); + /// + /// A dictionary relating a NetUserId to the jobs they have on station. + /// An OOC way to track where job slots have gone. + /// + [DataField] + public Dictionary>> PlayerJobs = new(); + [DataField("availableJobs", required: true, customTypeSerializer: typeof(PrototypeIdDictionarySerializer, JobPrototype>))] public Dictionary> SetupAvailableJobs = default!; diff --git a/Content.Server/Station/Systems/StationJobsSystem.cs b/Content.Server/Station/Systems/StationJobsSystem.cs index eeaace03b2..c13df410a0 100644 --- a/Content.Server/Station/Systems/StationJobsSystem.cs +++ b/Content.Server/Station/Systems/StationJobsSystem.cs @@ -9,7 +9,9 @@ using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared.Configuration; +using Robust.Shared.Network; using Robust.Shared.Player; +using Robust.Shared.Prototypes; using Robust.Shared.Random; namespace Content.Server.Station.Systems; @@ -84,13 +86,14 @@ private void OnStationInitialized(StationInitializedEvent msg) #region Public API - /// + /// /// Station to assign a job on. /// Job to assign. + /// The net user ID of the player we're assigning this job to. /// Resolve pattern, station jobs component of the station. - public bool TryAssignJob(EntityUid station, JobPrototype job, StationJobsComponent? stationJobs = null) + public bool TryAssignJob(EntityUid station, JobPrototype job, NetUserId netUserId, StationJobsComponent? stationJobs = null) { - return TryAssignJob(station, job.ID, stationJobs); + return TryAssignJob(station, job.ID, netUserId, stationJobs); } /// @@ -98,12 +101,21 @@ public bool TryAssignJob(EntityUid station, JobPrototype job, StationJobsCompone /// /// Station to assign a job on. /// Job prototype ID to assign. + /// The net user ID of the player we're assigning this job to. /// Resolve pattern, station jobs component of the station. /// Whether or not assignment was a success. /// Thrown when the given station is not a station. - public bool TryAssignJob(EntityUid station, string jobPrototypeId, StationJobsComponent? stationJobs = null) + public bool TryAssignJob(EntityUid station, string jobPrototypeId, NetUserId netUserId, StationJobsComponent? stationJobs = null) { - return TryAdjustJobSlot(station, jobPrototypeId, -1, false, false, stationJobs); + if (!Resolve(station, ref stationJobs, false)) + return false; + + if (!TryAdjustJobSlot(station, jobPrototypeId, -1, false, false, stationJobs)) + return false; + + stationJobs.PlayerJobs.TryAdd(netUserId, new()); + stationJobs.PlayerJobs[netUserId].Add(jobPrototypeId); + return true; } /// @@ -183,6 +195,28 @@ public bool TryAdjustJobSlot(EntityUid station, string jobPrototypeId, int amoun } } + public bool TryGetPlayerJobs(EntityUid station, + NetUserId userId, + [NotNullWhen(true)] out List>? jobs, + StationJobsComponent? jobsComponent = null) + { + jobs = null; + if (!Resolve(station, ref jobsComponent, false)) + return false; + + return jobsComponent.PlayerJobs.TryGetValue(userId, out jobs); + } + + public bool TryRemovePlayerJobs(EntityUid station, + NetUserId userId, + StationJobsComponent? jobsComponent = null) + { + if (!Resolve(station, ref jobsComponent, false)) + return false; + + return jobsComponent.PlayerJobs.Remove(userId); + } + /// /// Station to adjust the job slot on. /// Job prototype to adjust. diff --git a/Content.Shared/Access/Components/AccessReaderComponent.cs b/Content.Shared/Access/Components/AccessReaderComponent.cs index 5dd45b21c3..3f6c9e1c05 100644 --- a/Content.Shared/Access/Components/AccessReaderComponent.cs +++ b/Content.Shared/Access/Components/AccessReaderComponent.cs @@ -63,6 +63,12 @@ public sealed partial class AccessReaderComponent : Component /// [DataField, ViewVariables(VVAccess.ReadWrite)] public int AccessLogLimit = 20; + + /// + /// Whether or not emag interactions have an effect on this. + /// + [DataField] + public bool BreakOnEmag = true; } [DataDefinition, Serializable, NetSerializable] diff --git a/Content.Shared/Access/Components/IdCardConsoleComponent.cs b/Content.Shared/Access/Components/IdCardConsoleComponent.cs index 90786382c0..eafc78cad1 100644 --- a/Content.Shared/Access/Components/IdCardConsoleComponent.cs +++ b/Content.Shared/Access/Components/IdCardConsoleComponent.cs @@ -58,6 +58,7 @@ public WriteToTargetIdMessage(string fullName, string jobTitle, List acc "ChiefMedicalOfficer", "Clown", // DeltaV - Add Clown access "Command", + "Cryogenics", "Engineering", "External", "HeadOfPersonnel", diff --git a/Content.Shared/Access/Systems/AccessReaderSystem.cs b/Content.Shared/Access/Systems/AccessReaderSystem.cs index c5bceb4899..812a8e0487 100644 --- a/Content.Shared/Access/Systems/AccessReaderSystem.cs +++ b/Content.Shared/Access/Systems/AccessReaderSystem.cs @@ -76,6 +76,8 @@ private void OnLinkAttempt(EntityUid uid, AccessReaderComponent component, LinkA private void OnEmagged(EntityUid uid, AccessReaderComponent reader, ref GotEmaggedEvent args) { + if (!reader.BreakOnEmag) + return; args.Handled = true; reader.Enabled = false; reader.AccessLog.Clear(); diff --git a/Content.Shared/Bed/Cryostorage/CryostorageComponent.cs b/Content.Shared/Bed/Cryostorage/CryostorageComponent.cs new file mode 100644 index 0000000000..c7aa00c300 --- /dev/null +++ b/Content.Shared/Bed/Cryostorage/CryostorageComponent.cs @@ -0,0 +1,110 @@ +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; + +namespace Content.Shared.Bed.Cryostorage; + +/// +/// This is used for a container which, when a player logs out while inside of, +/// will delete their body and redistribute their items. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class CryostorageComponent : Component +{ + [DataField, ViewVariables(VVAccess.ReadWrite)] + public string ContainerId = "storage"; + + /// + /// How long a player can remain inside Cryostorage before automatically being taken care of, given that they have no mind. + /// + [DataField, ViewVariables(VVAccess.ReadWrite)] + public TimeSpan NoMindGracePeriod = TimeSpan.FromSeconds(30f); + + /// + /// How long a player can remain inside Cryostorage before automatically being taken care of. + /// + [DataField, ViewVariables(VVAccess.ReadWrite)] + public TimeSpan GracePeriod = TimeSpan.FromMinutes(5f); + + /// + /// A list of players who have actively entered cryostorage. + /// + [DataField] + public List StoredPlayers = new(); + + /// + /// Sound that is played when a player is removed by a cryostorage. + /// + [DataField] + public SoundSpecifier? RemoveSound = new SoundPathSpecifier("/Audio/Effects/teleport_departure.ogg"); +} + +[Serializable, NetSerializable] +public enum CryostorageVisuals : byte +{ + Full +} + +[Serializable, NetSerializable] +public record struct CryostorageContainedPlayerData() +{ + /// + /// The player's IC name + /// + public string PlayerName = string.Empty; + + /// + /// The player's entity + /// + public NetEntity PlayerEnt = NetEntity.Invalid; + + /// + /// A dictionary relating a slot definition name to the name of the item inside of it. + /// + public Dictionary ItemSlots = new(); + + /// + /// A dictionary relating a hand ID to the hand name and the name of the item being held. + /// + public Dictionary HeldItems = new(); +} + +[Serializable, NetSerializable] +public sealed class CryostorageBuiState : BoundUserInterfaceState +{ + public List PlayerData; + + public CryostorageBuiState(List playerData) + { + PlayerData = playerData; + } +} + +[Serializable, NetSerializable] +public sealed class CryostorageRemoveItemBuiMessage : BoundUserInterfaceMessage +{ + public NetEntity Entity; + + public string Key; + + public RemovalType Type; + + public enum RemovalType : byte + { + Hand, + Inventory + } + + public CryostorageRemoveItemBuiMessage(NetEntity entity, string key, RemovalType type) + { + Entity = entity; + Key = key; + Type = type; + } +} + +[Serializable, NetSerializable] +public enum CryostorageUIKey : byte +{ + Key +} diff --git a/Content.Shared/Bed/Cryostorage/CryostorageContainedComponent.cs b/Content.Shared/Bed/Cryostorage/CryostorageContainedComponent.cs new file mode 100644 index 0000000000..42a11aabe2 --- /dev/null +++ b/Content.Shared/Bed/Cryostorage/CryostorageContainedComponent.cs @@ -0,0 +1,34 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Network; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared.Bed.Cryostorage; + +/// +/// This is used to track an entity that is currently being held in Cryostorage. +/// +[RegisterComponent, NetworkedComponent] +[AutoGenerateComponentState] +public sealed partial class CryostorageContainedComponent : Component +{ + /// + /// Whether or not this entity is being stored on another map or is just chilling in a container + /// + [DataField, AutoNetworkedField] + public bool StoredWhileDisconnected; + + /// + /// The time at which the cryostorage grace period ends. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + public TimeSpan? GracePeriodEndTime; + + /// + /// The cryostorage this entity is 'stored' in. + /// + [DataField, AutoNetworkedField] + public EntityUid? Cryostorage; + + [DataField] + public NetUserId? UserId; +} diff --git a/Content.Shared/Bed/Cryostorage/SharedCryostorageSystem.cs b/Content.Shared/Bed/Cryostorage/SharedCryostorageSystem.cs new file mode 100644 index 0000000000..e781433783 --- /dev/null +++ b/Content.Shared/Bed/Cryostorage/SharedCryostorageSystem.cs @@ -0,0 +1,179 @@ +using Content.Shared.Administration.Logs; +using Content.Shared.CCVar; +using Content.Shared.DragDrop; +using Content.Shared.GameTicking; +using Content.Shared.Mind; +using Content.Shared.Mind.Components; +using Robust.Shared.Configuration; +using Robust.Shared.Containers; +using Robust.Shared.Map; +using Robust.Shared.Timing; + +namespace Content.Shared.Bed.Cryostorage; + +/// +/// This handles +/// +public abstract class SharedCryostorageSystem : EntitySystem +{ + [Dependency] protected readonly ISharedAdminLogManager AdminLog = default!; + [Dependency] private readonly IConfigurationManager _configuration = default!; + [Dependency] protected readonly IGameTiming Timing = default!; + [Dependency] private readonly IMapManager _mapManager = default!; + [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] protected readonly SharedMindSystem Mind = default!; + + protected EntityUid? PausedMap { get; private set; } + + protected bool CryoSleepRejoiningEnabled; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnInsertedContainer); + SubscribeLocalEvent(OnRemovedContainer); + SubscribeLocalEvent(OnInsertAttempt); + SubscribeLocalEvent(OnShutdownContainer); + SubscribeLocalEvent(OnCanDropTarget); + + SubscribeLocalEvent(OnRemovedContained); + SubscribeLocalEvent(OnUnpaused); + SubscribeLocalEvent(OnShutdownContained); + + SubscribeLocalEvent(OnRoundRestart); + + _configuration.OnValueChanged(CCVars.GameCryoSleepRejoining, OnCvarChanged); + } + + public override void Shutdown() + { + base.Shutdown(); + + _configuration.UnsubValueChanged(CCVars.GameCryoSleepRejoining, OnCvarChanged); + } + + private void OnCvarChanged(bool value) + { + CryoSleepRejoiningEnabled = value; + } + + protected virtual void OnInsertedContainer(Entity ent, ref EntInsertedIntoContainerMessage args) + { + var (_, comp) = ent; + if (args.Container.ID != comp.ContainerId) + return; + + _appearance.SetData(ent, CryostorageVisuals.Full, true); + if (!Timing.IsFirstTimePredicted) + return; + + var containedComp = EnsureComp(args.Entity); + var delay = Mind.TryGetMind(args.Entity, out _, out _) ? comp.GracePeriod : comp.NoMindGracePeriod; + containedComp.GracePeriodEndTime = Timing.CurTime + delay; + containedComp.Cryostorage = ent; + Dirty(args.Entity, containedComp); + } + + private void OnRemovedContainer(Entity ent, ref EntRemovedFromContainerMessage args) + { + var (_, comp) = ent; + if (args.Container.ID != comp.ContainerId) + return; + + _appearance.SetData(ent, CryostorageVisuals.Full, args.Container.ContainedEntities.Count > 0); + } + + private void OnInsertAttempt(Entity ent, ref ContainerIsInsertingAttemptEvent args) + { + var (_, comp) = ent; + if (args.Container.ID != comp.ContainerId) + return; + + if (!TryComp(args.EntityUid, out var mindContainer)) + { + args.Cancel(); + return; + } + + if (Mind.TryGetMind(args.EntityUid, out _, out var mindComp, mindContainer) && + (mindComp.PreventSuicide || mindComp.PreventGhosting)) + { + args.Cancel(); + } + } + + private void OnShutdownContainer(Entity ent, ref ComponentShutdown args) + { + var comp = ent.Comp; + foreach (var stored in comp.StoredPlayers) + { + if (TryComp(stored, out var containedComponent)) + { + containedComponent.Cryostorage = null; + Dirty(stored, containedComponent); + } + } + + comp.StoredPlayers.Clear(); + Dirty(ent, comp); + } + + private void OnCanDropTarget(Entity ent, ref CanDropTargetEvent args) + { + if (args.Dragged == args.User) + return; + + if (!Mind.TryGetMind(args.Dragged, out _, out var mindComp) || mindComp.Session?.AttachedEntity != args.Dragged) + return; + + args.CanDrop = false; + args.Handled = true; + } + + private void OnRemovedContained(Entity ent, ref EntGotRemovedFromContainerMessage args) + { + var (_, comp) = ent; + if (!comp.StoredWhileDisconnected) + RemCompDeferred(ent, comp); + } + + private void OnUnpaused(Entity ent, ref EntityUnpausedEvent args) + { + var comp = ent.Comp; + if (comp.GracePeriodEndTime != null) + comp.GracePeriodEndTime = comp.GracePeriodEndTime.Value + args.PausedTime; + } + + private void OnShutdownContained(Entity ent, ref ComponentShutdown args) + { + var comp = ent.Comp; + + CompOrNull(comp.Cryostorage)?.StoredPlayers.Remove(ent); + ent.Comp.Cryostorage = null; + Dirty(ent, comp); + } + + private void OnRoundRestart(RoundRestartCleanupEvent _) + { + DeletePausedMap(); + } + + private void DeletePausedMap() + { + if (PausedMap == null || !Exists(PausedMap)) + return; + + EntityManager.DeleteEntity(PausedMap.Value); + PausedMap = null; + } + + protected void EnsurePausedMap() + { + if (PausedMap != null && Exists(PausedMap)) + return; + + var map = _mapManager.CreateMap(); + _mapManager.SetMapPaused(map, true); + PausedMap = _mapManager.GetMapEntityId(map); + } +} diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index c1298d7769..e59e13ccf5 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -223,6 +223,12 @@ public static readonly CVarDef public static readonly CVarDef GameRoleTimers = CVarDef.Create("game.role_timers", true, CVar.SERVER | CVar.REPLICATED); + /// + /// Whether or not disconnecting inside of a cryopod should remove the character or just store them until they reconnect. + /// + public static readonly CVarDef + GameCryoSleepRejoining = CVarDef.Create("game.cryo_sleep_rejoining", false, CVar.SERVER | CVar.REPLICATED); + /// /// Whether a random position offset will be applied to the station on roundstart. /// diff --git a/Content.Shared/Climbing/Systems/ClimbSystem.cs b/Content.Shared/Climbing/Systems/ClimbSystem.cs index 21809f4756..c54149243a 100644 --- a/Content.Shared/Climbing/Systems/ClimbSystem.cs +++ b/Content.Shared/Climbing/Systems/ClimbSystem.cs @@ -247,7 +247,7 @@ private void Climb(EntityUid uid, EntityUid user, EntityUid climbable, bool sile if (!Resolve(uid, ref climbing, ref physics, ref fixtures, false)) return; - if (!Resolve(climbable, ref comp)) + if (!Resolve(climbable, ref comp, false)) return; if (!ReplaceFixtures(uid, climbing, fixtures)) diff --git a/Content.Shared/Containers/DragInsertContainerComponent.cs b/Content.Shared/Containers/DragInsertContainerComponent.cs new file mode 100644 index 0000000000..e4cae26fcb --- /dev/null +++ b/Content.Shared/Containers/DragInsertContainerComponent.cs @@ -0,0 +1,20 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Containers; + +/// +/// This is used for a container that can have entities inserted into it via a +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(DragInsertContainerSystem))] +public sealed partial class DragInsertContainerComponent : Component +{ + [DataField, ViewVariables(VVAccess.ReadWrite)] + public string ContainerId; + + /// + /// If true, there will also be verbs for inserting / removing objects from this container. + /// + [DataField, ViewVariables(VVAccess.ReadWrite)] + public bool UseVerbs = true; +} diff --git a/Content.Shared/Containers/DragInsertContainerSystem.cs b/Content.Shared/Containers/DragInsertContainerSystem.cs new file mode 100644 index 0000000000..6bba26504b --- /dev/null +++ b/Content.Shared/Containers/DragInsertContainerSystem.cs @@ -0,0 +1,120 @@ +using Content.Shared.ActionBlocker; +using Content.Shared.Administration.Logs; +using Content.Shared.Climbing.Systems; +using Content.Shared.Database; +using Content.Shared.DragDrop; +using Content.Shared.Verbs; +using Robust.Shared.Containers; + +namespace Content.Shared.Containers; + +public sealed class DragInsertContainerSystem : EntitySystem +{ + [Dependency] private readonly ISharedAdminLogManager _adminLog = default!; + [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; + [Dependency] private readonly ClimbSystem _climb = default!; + [Dependency] private readonly SharedContainerSystem _container = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnDragDropOn, before: new []{ typeof(ClimbSystem)}); + SubscribeLocalEvent(OnCanDragDropOn); + SubscribeLocalEvent>(OnGetAlternativeVerb); + } + + private void OnDragDropOn(Entity ent, ref DragDropTargetEvent args) + { + if (args.Handled) + return; + + var (_, comp) = ent; + if (!_container.TryGetContainer(ent, comp.ContainerId, out var container)) + return; + + args.Handled = Insert(args.Dragged, args.User, ent, container); + } + + private void OnCanDragDropOn(Entity ent, ref CanDropTargetEvent args) + { + var (_, comp) = ent; + if (!_container.TryGetContainer(ent, comp.ContainerId, out var container)) + return; + + args.Handled = true; + args.CanDrop |= _container.CanInsert(args.Dragged, container); + } + + private void OnGetAlternativeVerb(Entity ent, ref GetVerbsEvent args) + { + var (uid, comp) = ent; + if (!comp.UseVerbs) + return; + + if (!args.CanInteract || !args.CanAccess || args.Hands == null) + return; + + if (!_container.TryGetContainer(uid, comp.ContainerId, out var container)) + return; + + var user = args.User; + if (!_actionBlocker.CanInteract(user, ent)) + return; + + // Eject verb + if (container.ContainedEntities.Count > 0) + { + // make sure that we can actually take stuff out of the container + var emptyableCount = 0; + foreach (var contained in container.ContainedEntities) + { + if (!_container.CanRemove(contained, container)) + continue; + emptyableCount++; + } + + if (emptyableCount > 0) + { + AlternativeVerb verb = new() + { + Act = () => + { + _adminLog.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(user):player} emptied container {ToPrettyString(ent)}"); + var ents = _container.EmptyContainer(container); + foreach (var contained in ents) + { + _climb.ForciblySetClimbing(contained, ent); + } + }, + Category = VerbCategory.Eject, + Text = Loc.GetString("container-verb-text-empty"), + Priority = 1 // Promote to top to make ejecting the ALT-click action + }; + args.Verbs.Add(verb); + } + } + + // Self-insert verb + if (_container.CanInsert(user, container) && + _actionBlocker.CanMove(user)) + { + AlternativeVerb verb = new() + { + Act = () => Insert(user, user, ent, container), + Text = Loc.GetString("container-verb-text-enter"), + Priority = 2 + }; + args.Verbs.Add(verb); + } + } + + public bool Insert(EntityUid target, EntityUid user, EntityUid containerEntity, BaseContainer container) + { + if (!_container.Insert(user, container)) + return false; + + _adminLog.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(user):player} inserted {ToPrettyString(target):player} into container {ToPrettyString(containerEntity)}"); + return true; + } +} diff --git a/Content.Shared/Containers/ExitContainerOnMoveComponent.cs b/Content.Shared/Containers/ExitContainerOnMoveComponent.cs new file mode 100644 index 0000000000..aae4eec710 --- /dev/null +++ b/Content.Shared/Containers/ExitContainerOnMoveComponent.cs @@ -0,0 +1,14 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Containers; + +/// +/// This is used for a container that is exited when the entity inside of it moves. +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(ExitContainerOnMoveSystem))] +public sealed partial class ExitContainerOnMoveComponent : Component +{ + [DataField, ViewVariables(VVAccess.ReadWrite)] + public string ContainerId; +} diff --git a/Content.Shared/Containers/ExitContainerOnMoveSystem.cs b/Content.Shared/Containers/ExitContainerOnMoveSystem.cs new file mode 100644 index 0000000000..8b15618649 --- /dev/null +++ b/Content.Shared/Containers/ExitContainerOnMoveSystem.cs @@ -0,0 +1,31 @@ +using Content.Shared.Climbing.Systems; +using Content.Shared.Movement.Events; +using Robust.Shared.Containers; + +namespace Content.Shared.Containers; + +public sealed class ExitContainerOnMoveSystem : EntitySystem +{ + [Dependency] private readonly ClimbSystem _climb = default!; + [Dependency] private readonly SharedContainerSystem _container = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnContainerRelay); + } + + private void OnContainerRelay(Entity ent, ref ContainerRelayMovementEntityEvent args) + { + var (_, comp) = ent; + if (!TryComp(ent, out var containerManager)) + return; + + if (!_container.TryGetContainer(ent, comp.ContainerId, out var container, containerManager) || !container.Contains(args.Entity)) + return; + + _climb.ForciblySetClimbing(args.Entity, ent); + _container.RemoveEntity(ent, args.Entity, containerManager); + } +} diff --git a/Content.Shared/Inventory/InventorySystem.Slots.cs b/Content.Shared/Inventory/InventorySystem.Slots.cs index 65b050c1c4..210e21c2c9 100644 --- a/Content.Shared/Inventory/InventorySystem.Slots.cs +++ b/Content.Shared/Inventory/InventorySystem.Slots.cs @@ -99,7 +99,7 @@ public bool TryGetContainerSlotEnumerator(Entity entity, ou public InventorySlotEnumerator GetSlotEnumerator(Entity entity, SlotFlags flags = SlotFlags.All) { - if (!Resolve(entity.Owner, ref entity.Comp)) + if (!Resolve(entity.Owner, ref entity.Comp, false)) return InventorySlotEnumerator.Empty; return new InventorySlotEnumerator(entity.Comp, flags); diff --git a/Resources/Locale/en-US/containers/containers.ftl b/Resources/Locale/en-US/containers/containers.ftl new file mode 100644 index 0000000000..ab011f64f8 --- /dev/null +++ b/Resources/Locale/en-US/containers/containers.ftl @@ -0,0 +1,2 @@ +container-verb-text-enter = Enter +container-verb-text-empty = Empty diff --git a/Resources/Locale/en-US/prototypes/access/accesses.ftl b/Resources/Locale/en-US/prototypes/access/accesses.ftl index b3dc934b84..fcde748213 100644 --- a/Resources/Locale/en-US/prototypes/access/accesses.ftl +++ b/Resources/Locale/en-US/prototypes/access/accesses.ftl @@ -1,6 +1,7 @@ id-card-access-level-command = Command id-card-access-level-captain = Captain id-card-access-level-head-of-personnel = Head of Personnel +id-card-access-level-cryogenics = Cryogenics id-card-access-level-head-of-security = Head of Security id-card-access-level-security = Security diff --git a/Resources/Locale/en-US/round-end/cryostorage.ftl b/Resources/Locale/en-US/round-end/cryostorage.ftl new file mode 100644 index 0000000000..7b36b528b7 --- /dev/null +++ b/Resources/Locale/en-US/round-end/cryostorage.ftl @@ -0,0 +1,10 @@ +cryostorage-insert-message-permanent = [color=white]You are now inside of a [bold][color=cyan]cryogenic sleep unit[/color][/bold]. If you [bold]disconnect[/bold], [bold]ghost[/bold], or [bold]wait {$time} minutes[/bold], [color=red]your body will be removed[/color] and your job slot will be opened. You can exit at any time to prevent this.[/color] +cryostorage-insert-message-temp = [color=white]You are now inside of a [bold][color=cyan]cryogenic sleep unit[/color][/bold]. If you [bold]ghost[/bold] or [bold]wait {$time} minutes[/bold], [color=red]your body will be removed[/color] and your job slot will be opened. If you [bold][color=cyan]disconnect[/color][/bold], your body will be safely held until you rejoin.[/color] + +cryostorage-ui-window-title = Cryogenic Sleep Unit +cryostorage-ui-label-slot-name = [bold]{CAPITALIZE($slot)}:[/bold] +cryostorage-ui-button-remove = Remove +cryostorage-ui-filler-hand = inhand +cryostorage-ui-label-no-bodies = No bodies in cryostorage + +cryostorage-popup-access-denied = Access denied! diff --git a/Resources/Prototypes/Access/command.yml b/Resources/Prototypes/Access/command.yml index f71ca12f3b..62193d5ffe 100644 --- a/Resources/Prototypes/Access/command.yml +++ b/Resources/Prototypes/Access/command.yml @@ -16,6 +16,11 @@ - Command - Captain - HeadOfPersonnel + - Cryogenics - type: accessLevel id: EmergencyShuttleRepealAll + +- type: accessLevel + id: Cryogenics + name: id-card-access-level-cryogenics diff --git a/Resources/Prototypes/Access/misc.yml b/Resources/Prototypes/Access/misc.yml index 65bc5697db..ab17f6e41e 100644 --- a/Resources/Prototypes/Access/misc.yml +++ b/Resources/Prototypes/Access/misc.yml @@ -9,6 +9,7 @@ - HeadOfSecurity - ResearchDirector - Command + - Cryogenics - Security - Detective - Armory diff --git a/Resources/Prototypes/Access/security.yml b/Resources/Prototypes/Access/security.yml index c0af95d674..4a0743d174 100644 --- a/Resources/Prototypes/Access/security.yml +++ b/Resources/Prototypes/Access/security.yml @@ -27,6 +27,7 @@ - Armory #- Brig #Delta V removed - Detective + - Cryogenics - type: accessGroup id: Armory diff --git a/Resources/Prototypes/Entities/Structures/cryopod.yml b/Resources/Prototypes/Entities/Structures/cryopod.yml new file mode 100644 index 0000000000..c4d1388700 --- /dev/null +++ b/Resources/Prototypes/Entities/Structures/cryopod.yml @@ -0,0 +1,62 @@ +- type: entity + parent: BaseStructure + id: CryogenicSleepUnit + name: cryogenic sleep unit + description: A super-cooled container that keeps crewmates safe during space travel. + components: + - type: Sprite + noRot: true + sprite: Structures/cryostorage.rsi + layers: + - state: sleeper_0 + map: ["base"] + - type: UserInterface + interfaces: + - key: enum.CryostorageUIKey.Key + type: CryostorageBoundUserInterface + - type: ActivatableUI + key: enum.CryostorageUIKey.Key + - type: AccessReader + breakOnEmag: false + access: [["Cryogenics"]] + - type: InteractionOutline + - type: Cryostorage + - type: Climbable + - type: DragInsertContainer + containerId: storage + - type: ExitContainerOnMove + containerId: storage + - type: PointLight + color: Lime + radius: 1.5 + energy: 0.5 + castShadows: false + - type: ContainerContainer + containers: + storage: !type:ContainerSlot + - type: Appearance + - type: GenericVisualizer + visuals: + enum.CryostorageVisuals.Full: + base: + True: { state: sleeper_1 } + False: { state: sleeper_0 } + +# This one handles all spawns, latejoin and roundstart. +- type: entity + parent: CryogenicSleepUnit + id: CryogenicSleepUnitSpawner + suffix: Spawner, All + components: + - type: ContainerSpawnPoint + containerId: storage + +# This one only handles latejoin spawns. +- type: entity + parent: CryogenicSleepUnit + id: CryogenicSleepUnitSpawnerLateJoin + suffix: Spawner, LateJoin + components: + - type: ContainerSpawnPoint + containerId: storage + spawnType: LateJoin diff --git a/Resources/Textures/Structures/cryostorage.rsi/meta.json b/Resources/Textures/Structures/cryostorage.rsi/meta.json new file mode 100644 index 0000000000..24426d5b81 --- /dev/null +++ b/Resources/Textures/Structures/cryostorage.rsi/meta.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Taken from vg at commit https://github.com/vgstation-coders/vgstation13/commit/a16e41020a93479e9a7e2af343b1b74f7f2a61bd", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "sleeper_0", + "directions": 4 + }, + { + "name": "sleeper_1", + "directions": 4, + "delays": [ + [ + 0.2, + 0.2, + 0.2, + 0.2 + ], + [ + 0.2, + 0.2, + 0.2, + 0.2 + ], + [ + 0.2, + 0.2, + 0.2, + 0.2 + ], + [ + 0.2, + 0.2, + 0.2, + 0.2 + ] + ] + } + ] +} \ No newline at end of file diff --git a/Resources/Textures/Structures/cryostorage.rsi/sleeper_0.png b/Resources/Textures/Structures/cryostorage.rsi/sleeper_0.png new file mode 100644 index 0000000000000000000000000000000000000000..7f67b0decf583d37bd01d22a00272fc7e699b204 GIT binary patch literal 3110 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 literal 0 HcmV?d00001 diff --git a/SpaceStation14.sln.DotSettings b/SpaceStation14.sln.DotSettings index 42cf8d1cab..620de42253 100644 --- a/SpaceStation14.sln.DotSettings +++ b/SpaceStation14.sln.DotSettings @@ -585,6 +585,7 @@ public sealed partial class $CLASS$ : Shared$CLASS$ { True True True + True True True True