diff --git a/Content.Client/Clothing/Systems/PilotedByClothingSystem.cs b/Content.Client/Clothing/Systems/PilotedByClothingSystem.cs new file mode 100644 index 0000000000..c04cf0a60b --- /dev/null +++ b/Content.Client/Clothing/Systems/PilotedByClothingSystem.cs @@ -0,0 +1,19 @@ +using Content.Shared.Clothing.Components; +using Robust.Client.Physics; + +namespace Content.Client.Clothing.Systems; + +public sealed partial class PilotedByClothingSystem : EntitySystem +{ + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnUpdatePredicted); + } + + private void OnUpdatePredicted(Entity entity, ref UpdateIsPredictedEvent args) + { + args.BlockPrediction = true; + } +} diff --git a/Content.Server/Devour/DevourSystem.cs b/Content.Server/Devour/DevourSystem.cs index febbd093a6..d9c50f260a 100644 --- a/Content.Server/Devour/DevourSystem.cs +++ b/Content.Server/Devour/DevourSystem.cs @@ -1,3 +1,4 @@ +using Content.Server.Body.Components; using Content.Server.Body.Systems; using Content.Shared.Chemistry.Components; using Content.Shared.Devour; @@ -15,6 +16,7 @@ public sealed class DevourSystem : SharedDevourSystem base.Initialize(); SubscribeLocalEvent(OnDoAfter); + SubscribeLocalEvent(OnGibContents); } private void OnDoAfter(EntityUid uid, DevourerComponent component, DevourDoAfterEvent args) @@ -45,5 +47,15 @@ public sealed class DevourSystem : SharedDevourSystem _audioSystem.PlayPvs(component.SoundDevour, uid); } + + private void OnGibContents(EntityUid uid, DevourerComponent component, ref BeingGibbedEvent args) + { + if (!component.ShouldStoreDevoured) + return; + + // For some reason we have two different systems that should handle gibbing, + // and for some another reason GibbingSystem, which should empty all containers, doesn't get involved in this process + ContainerSystem.EmptyContainer(component.Stomach); + } } diff --git a/Content.Server/Teleportation/HandTeleporterSystem.cs b/Content.Server/Teleportation/HandTeleporterSystem.cs index 3d988b0916..1cd2e1d8c2 100644 --- a/Content.Server/Teleportation/HandTeleporterSystem.cs +++ b/Content.Server/Teleportation/HandTeleporterSystem.cs @@ -1,7 +1,9 @@ using Content.Server.Administration.Logs; +using Content.Server.Popups; using Content.Shared.DoAfter; using Content.Shared.Database; using Content.Shared.Interaction.Events; +using Content.Shared.Popups; using Content.Shared.Teleportation.Components; using Content.Shared.Teleportation.Systems; using Robust.Server.Audio; @@ -18,6 +20,7 @@ public sealed class HandTeleporterSystem : EntitySystem [Dependency] private readonly LinkedEntitySystem _link = default!; [Dependency] private readonly AudioSystem _audio = default!; [Dependency] private readonly SharedDoAfterSystem _doafter = default!; + [Dependency] private readonly PopupSystem _popup = default!; /// public override void Initialize() @@ -92,6 +95,16 @@ public sealed class HandTeleporterSystem : EntitySystem } else if (Deleted(component.SecondPortal)) { + if (xform.ParentUid != xform.GridUid) // Still, don't portal. + return; + + if (!component.AllowPortalsOnDifferentGrids && xform.ParentUid != Transform(component.FirstPortal!.Value).ParentUid) + { + // Whoops. Fizzle time. Crime time too because yippee I'm not refactoring this logic right now (I started to, I'm not going to.) + FizzlePortals(uid, component, user, true); + return; + } + var timeout = EnsureComp(user); timeout.EnteredPortal = null; component.SecondPortal = Spawn(component.SecondPortalPrototype, Transform(user).Coordinates); @@ -101,22 +114,32 @@ public sealed class HandTeleporterSystem : EntitySystem } else { - // Logging - var portalStrings = ""; - portalStrings += ToPrettyString(component.FirstPortal!.Value); - if (portalStrings != "") - portalStrings += " and "; - portalStrings += ToPrettyString(component.SecondPortal!.Value); - if (portalStrings != "") - _adminLogger.Add(LogType.EntityDelete, LogImpact.Low, $"{ToPrettyString(user):player} closed {portalStrings} with {ToPrettyString(uid)}"); - - // Clear both portals - QueueDel(component.FirstPortal!.Value); - QueueDel(component.SecondPortal!.Value); - - component.FirstPortal = null; - component.SecondPortal = null; - _audio.PlayPvs(component.ClearPortalsSound, uid); + FizzlePortals(uid, component, user, false); } } + + private void FizzlePortals(EntityUid uid, HandTeleporterComponent component, EntityUid user, bool instability) + { + // Logging + var portalStrings = ""; + portalStrings += ToPrettyString(component.FirstPortal); + if (portalStrings != "") + portalStrings += " and "; + portalStrings += ToPrettyString(component.SecondPortal); + if (portalStrings != "") + _adminLogger.Add(LogType.EntityDelete, LogImpact.Low, $"{ToPrettyString(user):player} closed {portalStrings} with {ToPrettyString(uid)}"); + + // Clear both portals + if (!Deleted(component.FirstPortal)) + QueueDel(component.FirstPortal.Value); + if (!Deleted(component.SecondPortal)) + QueueDel(component.SecondPortal.Value); + + component.FirstPortal = null; + component.SecondPortal = null; + _audio.PlayPvs(component.ClearPortalsSound, uid); + + if (instability) + _popup.PopupEntity(Loc.GetString("handheld-teleporter-instability-fizzle"), uid, user, PopupType.MediumCaution); + } } diff --git a/Content.Shared/Clothing/Components/PilotedByClothingComponent.cs b/Content.Shared/Clothing/Components/PilotedByClothingComponent.cs new file mode 100644 index 0000000000..cd4d0d6203 --- /dev/null +++ b/Content.Shared/Clothing/Components/PilotedByClothingComponent.cs @@ -0,0 +1,12 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Clothing.Components; + +/// +/// Disables client-side physics prediction for this entity. +/// Without this, movement with is very rubberbandy. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class PilotedByClothingComponent : Component +{ +} diff --git a/Content.Shared/Clothing/Components/PilotedClothingComponent.cs b/Content.Shared/Clothing/Components/PilotedClothingComponent.cs new file mode 100644 index 0000000000..a349e4e485 --- /dev/null +++ b/Content.Shared/Clothing/Components/PilotedClothingComponent.cs @@ -0,0 +1,38 @@ +using Content.Shared.Whitelist; +using Robust.Shared.GameStates; + +namespace Content.Shared.Clothing.Components; + +/// +/// Allows an entity stored in this clothing item to pass inputs to the entity wearing it. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class PilotedClothingComponent : Component +{ + /// + /// Whitelist for entities that are allowed to act as pilots when inside this entity. + /// + [DataField] + public EntityWhitelist? PilotWhitelist; + + /// + /// Should movement input be relayed from the pilot to the target? + /// + [DataField] + public bool RelayMovement = true; + + + /// + /// Reference to the entity contained in the clothing and acting as pilot. + /// + [DataField, AutoNetworkedField] + public EntityUid? Pilot; + + /// + /// Reference to the entity wearing this clothing who will be controlled by the pilot. + /// + [DataField, AutoNetworkedField] + public EntityUid? Wearer; + + public bool IsActive => Pilot != null && Wearer != null; +} diff --git a/Content.Shared/Clothing/EntitySystems/PilotedClothingSystem.cs b/Content.Shared/Clothing/EntitySystems/PilotedClothingSystem.cs new file mode 100644 index 0000000000..c3b6260094 --- /dev/null +++ b/Content.Shared/Clothing/EntitySystems/PilotedClothingSystem.cs @@ -0,0 +1,168 @@ +using Content.Shared.Clothing.Components; +using Content.Shared.Inventory.Events; +using Content.Shared.Movement.Components; +using Content.Shared.Movement.Systems; +using Content.Shared.Storage; +using Content.Shared.Whitelist; +using Robust.Shared.Containers; +using Robust.Shared.Timing; + +namespace Content.Shared.Clothing.EntitySystems; + +public sealed partial class PilotedClothingSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly SharedMoverController _moverController = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnEntInserted); + SubscribeLocalEvent(OnEntRemoved); + SubscribeLocalEvent(OnEquipped); + SubscribeLocalEvent(OnUnequipped); + } + + private void OnEntInserted(Entity entity, ref EntInsertedIntoContainerMessage args) + { + // Make sure the entity was actually inserted into storage and not a different container. + if (!TryComp(entity, out StorageComponent? storage) || args.Container != storage.Container) + return; + + // Check potential pilot against whitelist, if one exists. + if (entity.Comp.PilotWhitelist?.IsValid(args.Entity, EntityManager) is false) + return; + + entity.Comp.Pilot = args.Entity; + Dirty(entity); + + // Attempt to setup control link, if Pilot and Wearer are both present. + StartPiloting(entity); + } + + private void OnEntRemoved(Entity entity, ref EntRemovedFromContainerMessage args) + { + // Make sure the removed entity is actually the pilot. + if (args.Entity != entity.Comp.Pilot) + return; + + StopPiloting(entity); + entity.Comp.Pilot = null; + Dirty(entity); + } + + private void OnEquipped(Entity entity, ref GotEquippedEvent args) + { + if (!TryComp(entity, out ClothingComponent? clothing)) + return; + + // Make sure the clothing item was equipped to the right slot, and not just held in a hand. + var isCorrectSlot = (clothing.Slots & args.SlotFlags) != Inventory.SlotFlags.NONE; + if (!isCorrectSlot) + return; + + entity.Comp.Wearer = args.Equipee; + Dirty(entity); + + // Attempt to setup control link, if Pilot and Wearer are both present. + StartPiloting(entity); + } + + private void OnUnequipped(Entity entity, ref GotUnequippedEvent args) + { + StopPiloting(entity); + + entity.Comp.Wearer = null; + Dirty(entity); + } + + /// + /// Attempts to establish movement/interaction relay connection(s) from Pilot to Wearer. + /// If either is missing, fails and returns false. + /// + private bool StartPiloting(Entity entity) + { + // Make sure we have both a Pilot and a Wearer + if (entity.Comp.Pilot == null || entity.Comp.Wearer == null) + return false; + + if (!_timing.IsFirstTimePredicted) + return false; + + var pilotEnt = entity.Comp.Pilot.Value; + var wearerEnt = entity.Comp.Wearer.Value; + + // Add component to block prediction of wearer + EnsureComp(wearerEnt); + + if (entity.Comp.RelayMovement) + { + // Establish movement input relay. + _moverController.SetRelay(pilotEnt, wearerEnt); + } + + var pilotEv = new StartedPilotingClothingEvent(entity, wearerEnt); + RaiseLocalEvent(pilotEnt, ref pilotEv); + + var wearerEv = new StartingBeingPilotedByClothing(entity, pilotEnt); + RaiseLocalEvent(wearerEnt, ref wearerEv); + + return true; + } + + /// + /// Removes components from the Pilot and Wearer to stop the control relay. + /// Returns false if a connection does not already exist. + /// + private bool StopPiloting(Entity entity) + { + if (entity.Comp.Pilot == null || entity.Comp.Wearer == null) + return false; + + // Clean up components on the Pilot + var pilotEnt = entity.Comp.Pilot.Value; + RemCompDeferred(pilotEnt); + + // Clean up components on the Wearer + var wearerEnt = entity.Comp.Wearer.Value; + RemCompDeferred(wearerEnt); + RemCompDeferred(wearerEnt); + + // Raise an event on the Pilot + var pilotEv = new StoppedPilotingClothingEvent(entity, wearerEnt); + RaiseLocalEvent(pilotEnt, ref pilotEv); + + // Raise an event on the Wearer + var wearerEv = new StoppedBeingPilotedByClothing(entity, pilotEnt); + RaiseLocalEvent(wearerEnt, ref wearerEv); + + return true; + } +} + +/// +/// Raised on the Pilot when they gain control of the Wearer. +/// +[ByRefEvent] +public record struct StartedPilotingClothingEvent(EntityUid Clothing, EntityUid Wearer); + +/// +/// Raised on the Pilot when they lose control of the Wearer, +/// due to the Pilot exiting the clothing or the clothing being unequipped by the Wearer. +/// +[ByRefEvent] +public record struct StoppedPilotingClothingEvent(EntityUid Clothing, EntityUid Wearer); + +/// +/// Raised on the Wearer when the Pilot gains control of them. +/// +[ByRefEvent] +public record struct StartingBeingPilotedByClothing(EntityUid Clothing, EntityUid Pilot); + +/// +/// Raised on the Wearer when the Pilot loses control of them +/// due to the Pilot exiting the clothing or the clothing being unequipped by the Wearer. +/// +[ByRefEvent] +public record struct StoppedBeingPilotedByClothing(EntityUid Clothing, EntityUid Pilot); diff --git a/Content.Shared/Teleportation/Components/HandTeleporterComponent.cs b/Content.Shared/Teleportation/Components/HandTeleporterComponent.cs index 6abd4a7d21..6ea29d3fd6 100644 --- a/Content.Shared/Teleportation/Components/HandTeleporterComponent.cs +++ b/Content.Shared/Teleportation/Components/HandTeleporterComponent.cs @@ -1,4 +1,4 @@ -using Content.Shared.DoAfter; +using Content.Shared.DoAfter; using Robust.Shared.Audio; using Robust.Shared.GameStates; using Robust.Shared.Prototypes; @@ -20,6 +20,12 @@ public sealed partial class HandTeleporterComponent : Component [ViewVariables, DataField("secondPortal")] public EntityUid? SecondPortal = null; + /// + /// Portals can't be placed on different grids? + /// + [DataField] + public bool AllowPortalsOnDifferentGrids; + [DataField("firstPortalPrototype", customTypeSerializer: typeof(PrototypeIdSerializer))] public string FirstPortalPrototype = "PortalRed"; diff --git a/Resources/Locale/en-US/teleportation/handheld-teleporter.ftl b/Resources/Locale/en-US/teleportation/handheld-teleporter.ftl new file mode 100644 index 0000000000..28f526f0d5 --- /dev/null +++ b/Resources/Locale/en-US/teleportation/handheld-teleporter.ftl @@ -0,0 +1 @@ +handheld-teleporter-instability-fizzle = The portal fizzles as you try to place it, destroying both ends! diff --git a/Resources/Prototypes/Entities/Clothing/Head/hats.yml b/Resources/Prototypes/Entities/Clothing/Head/hats.yml index f555fa02c8..a420eab361 100644 --- a/Resources/Prototypes/Entities/Clothing/Head/hats.yml +++ b/Resources/Prototypes/Entities/Clothing/Head/hats.yml @@ -250,6 +250,10 @@ - type: ContainerContainer containers: storagebase: !type:Container + - type: PilotedClothing + pilotWhitelist: + tags: + - ChefPilot - type: Tag tags: - ClothMade diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml index a864ee8684..294f29beee 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml @@ -1584,6 +1584,7 @@ tags: - Trash - VimPilot + - ChefPilot - Mouse - Meat - type: Respirator @@ -3065,6 +3066,7 @@ - type: Tag tags: - VimPilot + - ChefPilot - Trash - Hamster - Meat diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml index 0a4c7b3519..2e719e4dcc 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml @@ -594,6 +594,7 @@ - CannotSuicide - Hamster - VimPilot + - ChefPilot - type: entity name: Shiva diff --git a/Resources/Prototypes/Reagents/botany.yml b/Resources/Prototypes/Reagents/botany.yml index a03c3826a4..4cb7d0c184 100644 --- a/Resources/Prototypes/Reagents/botany.yml +++ b/Resources/Prototypes/Reagents/botany.yml @@ -247,6 +247,8 @@ groups: Brute: -5 Burn: -5 + types: + Bloodloss: -5 - type: reagent diff --git a/Resources/Prototypes/tags.yml b/Resources/Prototypes/tags.yml index 829af28846..008a90eb04 100644 --- a/Resources/Prototypes/tags.yml +++ b/Resources/Prototypes/tags.yml @@ -361,6 +361,10 @@ - type: Tag id: Chicken +# Allowed to control someone wearing a Chef's hat if inside their hat. +- type: Tag + id: ChefPilot + - type: Tag id: ChemDispensable # container that can go into the chem dispenser