diff --git a/Content.Client/IgnoredComponents.cs b/Content.Client/IgnoredComponents.cs index a62268013d..b87324296a 100644 --- a/Content.Client/IgnoredComponents.cs +++ b/Content.Client/IgnoredComponents.cs @@ -250,6 +250,8 @@ namespace Content.Client "ToySpawner", "FigureSpawner", "RandomSpawner", + "SpawnAfterInteract", + "DisassembleOnActivate", }; } } diff --git a/Content.Server/GameObjects/Components/Engineering/DisassembleOnActivateComponent.cs b/Content.Server/GameObjects/Components/Engineering/DisassembleOnActivateComponent.cs new file mode 100644 index 0000000000..708d9c16db --- /dev/null +++ b/Content.Server/GameObjects/Components/Engineering/DisassembleOnActivateComponent.cs @@ -0,0 +1,28 @@ +#nullable enable +using Content.Shared.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.ViewVariables; +using System.Threading; + +namespace Content.Server.GameObjects.Components.Engineering + { + [RegisterComponent] + public class DisassembleOnActivateComponent : Component + { + public override string Name => "DisassembleOnActivate"; + public override uint? NetID => ContentNetIDs.DISASSEMBLE_ON_ACTIVATE; + + [ViewVariables] + [field: DataField("prototype", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string? Prototype { get; } + + [ViewVariables] + [DataField("doAfter")] + public float DoAfterTime = 0; + + public CancellationTokenSource TokenSource { get; } = new(); + } +} diff --git a/Content.Server/GameObjects/Components/Engineering/SpawnAfterInteractComponent.cs b/Content.Server/GameObjects/Components/Engineering/SpawnAfterInteractComponent.cs new file mode 100644 index 0000000000..d003dcbc31 --- /dev/null +++ b/Content.Server/GameObjects/Components/Engineering/SpawnAfterInteractComponent.cs @@ -0,0 +1,29 @@ +#nullable enable +using Content.Shared.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.ViewVariables; + +namespace Content.Server.GameObjects.Components.Engineering +{ + [RegisterComponent] + public class SpawnAfterInteractComponent : Component + { + public override string Name => "SpawnAfterInteract"; + public override uint? NetID => ContentNetIDs.SPAWN_AFTER_INTERACT; + + [ViewVariables] + [field: DataField("prototype", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string? Prototype { get; } + + [ViewVariables] + [DataField("doAfter")] + public float DoAfterTime = 0; + + [ViewVariables] + [DataField("removeOnInteract")] + public bool RemoveOnInteract = false; + } +} diff --git a/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs b/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs index 5f425bf83a..5a0abab666 100644 --- a/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs +++ b/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading.Tasks; using Content.Server.GameObjects.Components.Items.Storage; @@ -131,7 +131,7 @@ namespace Content.Server.GameObjects.EntitySystems.Click private void InteractionActivate(IEntity user, IEntity used) { var activateMsg = new ActivateInWorldMessage(user, used); - RaiseLocalEvent(activateMsg); + RaiseLocalEvent(used.Uid, activateMsg); if (activateMsg.Handled) { return; diff --git a/Content.Server/GameObjects/EntitySystems/DisassembleOnActivateSystem.cs b/Content.Server/GameObjects/EntitySystems/DisassembleOnActivateSystem.cs new file mode 100644 index 0000000000..e9b9928b6a --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/DisassembleOnActivateSystem.cs @@ -0,0 +1,68 @@ +using Content.Server.GameObjects.Components.Engineering; +using Content.Server.GameObjects.Components.GUI; +using Content.Server.GameObjects.Components.Items.Storage; +using Content.Server.GameObjects.Components.Stack; +using Content.Server.GameObjects.EntitySystems.DoAfter; +using Content.Shared.Interfaces.GameObjects.Components; +using Content.Shared.Utility; +using JetBrains.Annotations; +using Robust.Shared.GameObjects; +using System.Threading; + +namespace Content.Server.GameObjects.EntitySystems +{ + [UsedImplicitly] + public class DisassembleOnActivateSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(HandleActivateInWorld); + } + + public override void Shutdown() + { + base.Shutdown(); + + UnsubscribeLocalEvent(HandleActivateInWorld); + } + + private async void HandleActivateInWorld(EntityUid uid, DisassembleOnActivateComponent component, ActivateInWorldMessage args) + { + if (string.IsNullOrEmpty(component.Prototype)) + return; + if (!args.User.InRangeUnobstructed(args.Activated)) + return; + + if (component.DoAfterTime > 0 && TryGet(out var doAfterSystem)) + { + var doAfterArgs = new DoAfterEventArgs(args.User, component.DoAfterTime, component.TokenSource.Token) + { + BreakOnUserMove = true, + BreakOnStun = true, + }; + var result = await doAfterSystem.DoAfter(doAfterArgs); + + if (result != DoAfterStatus.Finished) + return; + component.TokenSource.Cancel(); + } + + if (component.Deleted || component.Owner.Deleted) + return; + + var entity = EntityManager.SpawnEntity(component.Prototype, component.Owner.Transform.Coordinates); + + if (args.User.TryGetComponent(out var hands) + && entity.TryGetComponent(out var item)) + { + hands.PutInHandOrDrop(item); + } + + component.Owner.Delete(); + + return; + } + } +} diff --git a/Content.Server/GameObjects/EntitySystems/SpawnAfterInteractSystem.cs b/Content.Server/GameObjects/EntitySystems/SpawnAfterInteractSystem.cs new file mode 100644 index 0000000000..13ddee9d83 --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/SpawnAfterInteractSystem.cs @@ -0,0 +1,79 @@ +#nullable enable +using Content.Server.GameObjects.Components.Engineering; +using Content.Server.GameObjects.Components.Stack; +using Content.Server.GameObjects.EntitySystems.DoAfter; +using Content.Server.Utility; +using Content.Shared.Interfaces.GameObjects.Components; +using Content.Shared.Maps; +using Content.Shared.Utility; +using JetBrains.Annotations; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Map; + +namespace Content.Server.GameObjects.EntitySystems +{ + [UsedImplicitly] + public class SpawnAfterInteractSystem : EntitySystem + { + [Dependency] private readonly IMapManager _mapManager = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(HandleAfterInteract); + } + + public override void Shutdown() + { + base.Shutdown(); + + UnsubscribeLocalEvent(HandleAfterInteract); + } + + private async void HandleAfterInteract(EntityUid uid, SpawnAfterInteractComponent component, AfterInteractMessage args) + { + if (string.IsNullOrEmpty(component.Prototype)) + return; + if (!args.ClickLocation.TryGetTileRef(out var tileRef, EntityManager, _mapManager)) + return; + + bool IsTileClear() + { + return tileRef.Value.Tile.IsEmpty == false && args.User.InRangeUnobstructed(args.ClickLocation, popup: true); + } + + if (!IsTileClear()) + return; + + if (component.DoAfterTime > 0 && TryGet(out var doAfterSystem)) + { + var doAfterArgs = new DoAfterEventArgs(args.User, component.DoAfterTime) + { + BreakOnUserMove = true, + BreakOnStun = true, + PostCheck = IsTileClear, + }; + var result = await doAfterSystem.DoAfter(doAfterArgs); + + if (result != DoAfterStatus.Finished) + return; + } + + if (component.Deleted || component.Owner.Deleted) + return; + + StackComponent? stack = null; + if (component.RemoveOnInteract && component.Owner.TryGetComponent(out stack) && !stack.Use(1)) + return; + + EntityManager.SpawnEntity(component.Prototype, tileRef.Value.GridPosition(_mapManager)); + + if (component.RemoveOnInteract && stack == null && !component.Owner.Deleted) + component.Owner.Delete(); + + return; + } + } +} diff --git a/Content.Shared/GameObjects/ContentNetIDs.cs b/Content.Shared/GameObjects/ContentNetIDs.cs index 4094687206..d7f12ed5f2 100644 --- a/Content.Shared/GameObjects/ContentNetIDs.cs +++ b/Content.Shared/GameObjects/ContentNetIDs.cs @@ -95,6 +95,8 @@ namespace Content.Shared.GameObjects public const uint TAG = 1086; // Used for clientside fake prediction of doors. public const uint DOOR = 1087; + public const uint SPAWN_AFTER_INTERACT = 1088; + public const uint DISASSEMBLE_ON_ACTIVATE = 1089; // Net IDs for integration tests. public const uint PREDICTION_TEST = 10001; diff --git a/Content.Shared/GameObjects/EntitySystems/SharedInteractionSystem.cs b/Content.Shared/GameObjects/EntitySystems/SharedInteractionSystem.cs index 7c3ea58a9c..b22634441e 100644 --- a/Content.Shared/GameObjects/EntitySystems/SharedInteractionSystem.cs +++ b/Content.Shared/GameObjects/EntitySystems/SharedInteractionSystem.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using Content.Shared.Interfaces; using Content.Shared.Interfaces.GameObjects.Components; using Content.Shared.Physics; @@ -541,5 +541,63 @@ namespace Content.Shared.GameObjects.EntitySystems return InRangeUnobstructed(user, otherPosition, range, collisionMask, predicate, ignoreInsideBlocker, popup); } + + /// + /// Checks that the user and target of a + /// are within a certain distance + /// without any entity that matches the collision mask obstructing them. + /// If the is zero or negative, + /// this method will only check if nothing obstructs the entity and component. + /// + /// The event args to use. + /// + /// Maximum distance between the two entity and set of map coordinates. + /// + /// The mask to check for collisions. + /// + /// A predicate to check whether to ignore an entity or not. + /// If it returns true, it will be ignored. + /// + /// + /// If true and both the user and target are inside + /// the obstruction, ignores the obstruction and considers the interaction + /// unobstructed. + /// Therefore, setting this to true makes this check more permissive, + /// such as allowing an interaction to occur inside something impassable + /// (like a wall). The default, false, makes the check more restrictive. + /// + /// + /// Whether or not to popup a feedback message on the user entity for + /// it to see. + /// + /// + /// True if the two points are within a given range without being obstructed. + /// + public bool InRangeUnobstructed( + AfterInteractMessage args, + float range = InteractionRange, + CollisionGroup collisionMask = CollisionGroup.Impassable, + Ignored? predicate = null, + bool ignoreInsideBlocker = false, + bool popup = false) + { + var user = args.User; + var target = args.Attacked; + predicate ??= e => e == user; + + MapCoordinates otherPosition; + + if (target == null) + { + otherPosition = args.ClickLocation.ToMap(EntityManager); + } + else + { + otherPosition = target.Transform.MapPosition; + predicate += e => e == target; + } + + return InRangeUnobstructed(user, otherPosition, range, collisionMask, predicate, ignoreInsideBlocker, popup); + } } } diff --git a/Content.Shared/Maps/TurfHelpers.cs b/Content.Shared/Maps/TurfHelpers.cs index cbc776da61..aa0c19cdc8 100644 --- a/Content.Shared/Maps/TurfHelpers.cs +++ b/Content.Shared/Maps/TurfHelpers.cs @@ -58,9 +58,9 @@ namespace Content.Shared.Maps return tile; } - public static bool TryGetTileRef(this EntityCoordinates coordinates, [NotNullWhen(true)] out TileRef? turf) + public static bool TryGetTileRef(this EntityCoordinates coordinates, [NotNullWhen(true)] out TileRef? turf, IEntityManager? entityManager = null, IMapManager? mapManager = null) { - return (turf = coordinates.GetTileRef()) != null; + return (turf = coordinates.GetTileRef(entityManager, mapManager)) != null; } /// diff --git a/Content.Shared/Utility/SharedUnobstructedExtensions.cs b/Content.Shared/Utility/SharedUnobstructedExtensions.cs index 7efb88f6f5..1f2ec3c13c 100644 --- a/Content.Shared/Utility/SharedUnobstructedExtensions.cs +++ b/Content.Shared/Utility/SharedUnobstructedExtensions.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using Content.Shared.GameObjects.EntitySystems; using Content.Shared.Interfaces.GameObjects.Components; using Content.Shared.Physics; @@ -430,5 +430,19 @@ namespace Content.Shared.Utility ignoreInsideBlocker, popup); } #endregion + + #region EntityEventArgs + public static bool InRangeUnobstructed( + this AfterInteractMessage args, + float range = InteractionRange, + CollisionGroup collisionMask = CollisionGroup.Impassable, + Ignored? predicate = null, + bool ignoreInsideBlocker = false, + bool popup = false) + { + return SharedInteractionSystem.InRangeUnobstructed(args, range, collisionMask, predicate, + ignoreInsideBlocker, popup); + } + #endregion } } diff --git a/Resources/Prototypes/Damage/resistance_sets.yml b/Resources/Prototypes/Damage/resistance_sets.yml index 47fe96ef92..865a9ff8b6 100644 --- a/Resources/Prototypes/Damage/resistance_sets.yml +++ b/Resources/Prototypes/Damage/resistance_sets.yml @@ -24,7 +24,7 @@ Asphyxiation: 0 Bloodloss: 0 Cellular: 0 - + - type: resistanceSet id: dionaResistances coefficients: @@ -51,7 +51,7 @@ Asphyxiation: 0 Bloodloss: 0 Cellular: 0 - + - type: resistanceSet id: metallicResistances coefficients: @@ -77,4 +77,31 @@ Radiation: 0 Asphyxiation: 0 Bloodloss: 0 - Cellular: 0 \ No newline at end of file + Cellular: 0 + +- type: resistanceSet + id: inflatableResistances + coefficients: + Blunt: 0.5 + Slash: 1.0 + Piercing: 2.0 + Heat: 0.5 + Shock: 0 + Cold: 0 + Poison: 0 + Radiation: 0 + Asphyxiation: 0 + Bloodloss: 0 + Cellular: 0 + flatReductions: + Blunt: 5 + Slash: 0 + Piercing: 0 + Heat: 0 + Shock: 0 + Cold: 0 + Poison: 0 + Radiation: 0 + Asphyxiation: 0 + Bloodloss: 0 + Cellular: 0 diff --git a/Resources/Prototypes/Entities/Objects/Misc/inflatable_wall.yml b/Resources/Prototypes/Entities/Objects/Misc/inflatable_wall.yml new file mode 100644 index 0000000000..bb25fbabd0 --- /dev/null +++ b/Resources/Prototypes/Entities/Objects/Misc/inflatable_wall.yml @@ -0,0 +1,40 @@ +- type: entity + id: InflatableWall + name: inflatable barricade + description: An inflated membrane. Activate to deflate. Do not puncture. + components: + - type: Clickable + - type: InteractionOutline + - type: Sprite + sprite: Objects/Misc/inflatable_wall.rsi + state: inflatable_wall + - type: Physics + bodyType: Static + fixtures: + - shape: + !type:PhysShapeAabb + bounds: "-0.5, -0.5, 0.5, 0.5" + mass: 15 + layer: + - Impassable + - MobImpassable + - VaultImpassable + - SmallImpassable + - type: Damageable + resistances: inflatableResistances + - type: Destructible + thresholds: + - trigger: + !type:DamageTrigger + damage: 20 + behaviors: + - !type:DoActsBehavior + acts: [ "Destruction" ] + - type: DisassembleOnActivate + prototype: InflatableWallStack1 + doAfter: 3 + - type: Airtight + - type: SnapGrid + offset: Center + placement: + mode: SnapgridCenter diff --git a/Resources/Prototypes/Entities/Objects/Tools/inflatable_wall.yml b/Resources/Prototypes/Entities/Objects/Tools/inflatable_wall.yml new file mode 100644 index 0000000000..e4b935066c --- /dev/null +++ b/Resources/Prototypes/Entities/Objects/Tools/inflatable_wall.yml @@ -0,0 +1,40 @@ +- type: entity + id: InflatableWallStack + parent: BaseItem + name: inflatable barricade + description: A folded membrane which rapidly expands into a large cubical shape on activation. + suffix: Full + components: + - type: Stack + stackType: InflatableWall + - type: Sprite + sprite: Objects/Misc/inflatable_wall.rsi + state: item_wall + netsync: false + - type: Item + sprite: Objects/Misc/inflatable_wall.rsi + size: 5 + - type: SpawnAfterInteract + prototype: InflatableWall + doAfter: 1 + removeOnInteract: true + - type: Clickable +# - type: Appearance # TODO: Add stack sprites +# visuals: +# - type: StackVisualizer +# stackLayers: +# - coillv-10 +# - coillv-20 +# - coillv-30 + +- type: entity + parent: InflatableWallStack + id: InflatableWallStack1 + suffix: 1 + components: + - type: Sprite + state: item_wall + - type: Item + size: 5 + - type: Stack + count: 1 \ No newline at end of file diff --git a/Resources/Prototypes/Stacks/engineering_stacks.yml b/Resources/Prototypes/Stacks/engineering_stacks.yml new file mode 100644 index 0000000000..d7abc36d66 --- /dev/null +++ b/Resources/Prototypes/Stacks/engineering_stacks.yml @@ -0,0 +1,4 @@ +- type: stack + id: InflatableWall + name: inflatable wall + spawn: InflatableWallStack1 diff --git a/Resources/Textures/Objects/Misc/inflatable_wall.rsi/inflatable_wall.png b/Resources/Textures/Objects/Misc/inflatable_wall.rsi/inflatable_wall.png new file mode 100644 index 0000000000..655c601b40 Binary files /dev/null and b/Resources/Textures/Objects/Misc/inflatable_wall.rsi/inflatable_wall.png differ diff --git a/Resources/Textures/Objects/Misc/inflatable_wall.rsi/item_wall.png b/Resources/Textures/Objects/Misc/inflatable_wall.rsi/item_wall.png new file mode 100644 index 0000000000..64c8847590 Binary files /dev/null and b/Resources/Textures/Objects/Misc/inflatable_wall.rsi/item_wall.png differ diff --git a/Resources/Textures/Objects/Misc/inflatable_wall.rsi/meta.json b/Resources/Textures/Objects/Misc/inflatable_wall.rsi/meta.json new file mode 100644 index 0000000000..f10663bb58 --- /dev/null +++ b/Resources/Textures/Objects/Misc/inflatable_wall.rsi/meta.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "inflatable_wall taken from https://github.com/discordia-space/CEV-Eris/blob/c34c1b30abf18aa552e19294523924c39e5ea127/icons/obj/inflatable.dmi and modified. item_wall by ShadowCommander.", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "inflatable_wall" + }, + { + "name": "item_wall" + } + ] +}