diff --git a/Content.Client/Blob/BlobChemSwapBoundUserInterface.cs b/Content.Client/Blob/BlobChemSwapBoundUserInterface.cs
new file mode 100644
index 00000000000..60454ea9030
--- /dev/null
+++ b/Content.Client/Blob/BlobChemSwapBoundUserInterface.cs
@@ -0,0 +1,51 @@
+using Content.Shared.Blob;
+using JetBrains.Annotations;
+using Robust.Client.GameObjects;
+
+namespace Content.Client.Blob;
+
+[UsedImplicitly]
+public sealed class BlobChemSwapBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ private BlobChemSwapMenu? _menu;
+
+ public BlobChemSwapBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _menu = new BlobChemSwapMenu();
+ _menu.OnClose += Close;
+ _menu.OnIdSelected += OnIdSelected;
+ _menu.OpenCentered();
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+ if (state is not BlobChemSwapBoundUserInterfaceState st)
+ return;
+
+ _menu?.UpdateState(st.ChemList, st.SelectedChem);
+ }
+
+ private void OnIdSelected(BlobChemType selectedId)
+ {
+ SendMessage(new BlobChemSwapPrototypeSelectedMessage(selectedId));
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ _menu?.Close();
+ _menu = null;
+ }
+ }
+}
diff --git a/Content.Client/Blob/BlobChemSwapMenu.xaml b/Content.Client/Blob/BlobChemSwapMenu.xaml
new file mode 100644
index 00000000000..297b7aeb8e9
--- /dev/null
+++ b/Content.Client/Blob/BlobChemSwapMenu.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Blob/BlobChemSwapMenu.xaml.cs b/Content.Client/Blob/BlobChemSwapMenu.xaml.cs
new file mode 100644
index 00000000000..3732d19cb01
--- /dev/null
+++ b/Content.Client/Blob/BlobChemSwapMenu.xaml.cs
@@ -0,0 +1,77 @@
+using System.Numerics;
+using Content.Client.Stylesheets;
+using Content.Shared.Blob;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Blob;
+
+[GenerateTypedNameReferences]
+public sealed partial class BlobChemSwapMenu : DefaultWindow
+{
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ private readonly SpriteSystem _sprite;
+ public event Action? OnIdSelected;
+
+ private Dictionary _possibleChems = new();
+ private BlobChemType _selectedId;
+
+ public BlobChemSwapMenu()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ _sprite = _entityManager.System();
+ }
+
+ public void UpdateState(Dictionary chemList, BlobChemType selectedChem)
+ {
+ _possibleChems = chemList;
+ _selectedId = selectedChem;
+ UpdateGrid();
+ }
+
+ private void UpdateGrid()
+ {
+ ClearGrid();
+
+ var group = new ButtonGroup();
+
+ foreach (var blobChem in _possibleChems)
+ {
+ if (!_prototypeManager.TryIndex("NormalBlobTile", out EntityPrototype? proto))
+ continue;
+
+ var button = new Button
+ {
+ MinSize = new Vector2(64, 64),
+ HorizontalExpand = true,
+ Group = group,
+ StyleClasses = {StyleBase.ButtonSquare},
+ ToggleMode = true,
+ Pressed = _selectedId == blobChem.Key,
+ ToolTip = Loc.GetString($"blob-chem-{blobChem.Key.ToString().ToLower()}-info"),
+ TooltipDelay = 0.01f,
+ };
+ button.OnPressed += _ => OnIdSelected?.Invoke(blobChem.Key);
+ Grid.AddChild(button);
+
+ var texture = _sprite.GetPrototypeIcon(proto);
+ button.AddChild(new TextureRect
+ {
+ Stretch = TextureRect.StretchMode.KeepAspectCentered,
+ Modulate = blobChem.Value,
+ Texture = texture.Default,
+ });
+ }
+ }
+
+ private void ClearGrid()
+ {
+ Grid.RemoveAllChildren();
+ }
+}
diff --git a/Content.Client/Blob/BlobObserverSystem.cs b/Content.Client/Blob/BlobObserverSystem.cs
new file mode 100644
index 00000000000..32dc213e333
--- /dev/null
+++ b/Content.Client/Blob/BlobObserverSystem.cs
@@ -0,0 +1,45 @@
+using Content.Shared.Blob;
+using Content.Shared.GameTicking;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Client.Blob;
+
+public sealed class BlobObserverSystem : SharedBlobObserverSystem
+{
+ [Dependency] private readonly ILightManager _lightManager = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(HandleState);
+ SubscribeLocalEvent(OnPlayerAttached);
+ SubscribeLocalEvent(OnPlayerDetached);
+ SubscribeNetworkEvent(RoundRestartCleanup);
+ }
+
+ private void HandleState(EntityUid uid, BlobObserverComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is not BlobChemSwapComponentState state)
+ return;
+ component.SelectedChemId = state.SelectedChem;
+ }
+
+ private void OnPlayerAttached(EntityUid uid, BlobObserverComponent component, PlayerAttachedEvent args)
+ {
+ _lightManager.DrawLighting = false;
+ }
+
+ private void OnPlayerDetached(EntityUid uid, BlobObserverComponent component, PlayerDetachedEvent args)
+ {
+ _lightManager.DrawLighting = true;
+ }
+
+ private void RoundRestartCleanup(RoundRestartCleanupEvent ev)
+ {
+ _lightManager.DrawLighting = true;
+ }
+}
diff --git a/Content.Client/Blob/BlobTileComponent.cs b/Content.Client/Blob/BlobTileComponent.cs
new file mode 100644
index 00000000000..f30e75c83ca
--- /dev/null
+++ b/Content.Client/Blob/BlobTileComponent.cs
@@ -0,0 +1,9 @@
+using Content.Shared.Blob;
+
+namespace Content.Client.Blob;
+
+[RegisterComponent]
+public partial class BlobTileComponent : SharedBlobTileComponent
+{
+
+}
diff --git a/Content.Client/Blob/BlobTileSystem.cs b/Content.Client/Blob/BlobTileSystem.cs
new file mode 100644
index 00000000000..28de9f916c6
--- /dev/null
+++ b/Content.Client/Blob/BlobTileSystem.cs
@@ -0,0 +1,38 @@
+using Content.Client.DamageState;
+using Content.Shared.Blob;
+using Robust.Client.GameObjects;
+using Robust.Shared.GameStates;
+
+namespace Content.Client.Blob;
+
+public sealed class BlobTileSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnBlobTileHandleState);
+ }
+
+ private void OnBlobTileHandleState(EntityUid uid, BlobTileComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is not BlobTileComponentState state)
+ return;
+
+ if (component.Color == state.Color)
+ return;
+
+ component.Color = state.Color;
+ TryComp(uid, out var sprite);
+
+ if (sprite == null)
+ return;
+
+ foreach (var key in new []{ DamageStateVisualLayers.Base, DamageStateVisualLayers.BaseUnshaded })
+ {
+ if (!sprite.LayerMapTryGet(key, out _))
+ continue;
+
+ sprite.LayerSetColor(key, component.Color);
+ }
+ }
+}
diff --git a/Content.Client/Blob/BlobbernautComponent.cs b/Content.Client/Blob/BlobbernautComponent.cs
new file mode 100644
index 00000000000..fb7430736e7
--- /dev/null
+++ b/Content.Client/Blob/BlobbernautComponent.cs
@@ -0,0 +1,9 @@
+using Content.Shared.Blob;
+
+namespace Content.Client.Blob;
+
+[RegisterComponent]
+public partial class BlobbernautComponent : SharedBlobbernautComponent
+{
+
+}
diff --git a/Content.Client/Blob/BlobbernautSystem.cs b/Content.Client/Blob/BlobbernautSystem.cs
new file mode 100644
index 00000000000..9a757ca43a5
--- /dev/null
+++ b/Content.Client/Blob/BlobbernautSystem.cs
@@ -0,0 +1,39 @@
+using System.Linq;
+using Content.Client.DamageState;
+using Content.Shared.Blob;
+using Robust.Client.GameObjects;
+using Robust.Shared.GameStates;
+
+namespace Content.Client.Blob;
+
+public sealed class BlobbernautSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnBlobTileHandleState);
+ }
+
+ private void OnBlobTileHandleState(EntityUid uid, BlobbernautComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is not BlobbernautComponentState state)
+ return;
+
+ if (component.Color == state.Color)
+ return;
+
+ component.Color = state.Color;
+ TryComp(uid, out var sprite);
+
+ if (sprite == null)
+ return;
+
+ foreach (var key in new []{ DamageStateVisualLayers.Base })
+ {
+ if (!sprite.LayerMapTryGet(key, out _))
+ continue;
+
+ sprite.LayerSetColor(key, component.Color);
+ }
+ }
+}
diff --git a/Content.Client/Chemistry/Components/SmokeComponent.cs b/Content.Client/Chemistry/Components/SmokeComponent.cs
new file mode 100644
index 00000000000..d57e17266ec
--- /dev/null
+++ b/Content.Client/Chemistry/Components/SmokeComponent.cs
@@ -0,0 +1,9 @@
+using Content.Shared.Chemistry.Components;
+
+namespace Content.Client.Chemistry.Components;
+
+[RegisterComponent]
+public partial class SmokeComponent : SharedSmokeComponent
+{
+
+}
diff --git a/Content.Client/Chemistry/Components/SmokeSystem.cs b/Content.Client/Chemistry/Components/SmokeSystem.cs
new file mode 100644
index 00000000000..65ecc5bf79e
--- /dev/null
+++ b/Content.Client/Chemistry/Components/SmokeSystem.cs
@@ -0,0 +1,35 @@
+using System.Linq;
+using Content.Shared.Chemistry.Components;
+using Robust.Client.GameObjects;
+using Robust.Shared.GameStates;
+
+namespace Content.Client.Chemistry.Components;
+
+public sealed class SmokeSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnBlobTileHandleState);
+ }
+
+ private void OnBlobTileHandleState(EntityUid uid, SmokeComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is not SmokeComponentState state)
+ return;
+
+ if (component.Color == state.Color)
+ return;
+
+ component.Color = state.Color;
+ TryComp(uid, out var sprite);
+
+ if (sprite == null)
+ return;
+
+ for (var i = 0; i < sprite.AllLayers.Count(); i++)
+ {
+ sprite.LayerSetColor(i, component.Color);
+ }
+ }
+}
diff --git a/Content.Server/Blob/BlobBorderComponent.cs b/Content.Server/Blob/BlobBorderComponent.cs
new file mode 100644
index 00000000000..3ef032851eb
--- /dev/null
+++ b/Content.Server/Blob/BlobBorderComponent.cs
@@ -0,0 +1,8 @@
+namespace Content.Server.Blob
+{
+ [RegisterComponent]
+ public sealed class BlobBorderComponent : Component
+ {
+
+ }
+}
diff --git a/Content.Server/Blob/BlobCarrierSystem.cs b/Content.Server/Blob/BlobCarrierSystem.cs
new file mode 100644
index 00000000000..cca11668052
--- /dev/null
+++ b/Content.Server/Blob/BlobCarrierSystem.cs
@@ -0,0 +1,131 @@
+using Content.Server.Actions;
+using Content.Shared.Mind.Components;
+using Content.Shared.Actions.ActionTypes;
+using Content.Server.Body.Systems;
+using Content.Server.Ghost.Roles.Components;
+using Content.Server.Mind;
+using Content.Shared.Blob;
+using Content.Shared.Mobs;
+using Content.Shared.Popups;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Blob
+{
+ public sealed class BlobCarrierSystem : EntitySystem
+ {
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly BlobCoreSystem _blobCoreSystem = default!;
+ [Dependency] private readonly MindSystem _mind = default!;
+ [Dependency] private readonly BodySystem _bodySystem = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly ActionsSystem _action = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMobStateChanged);
+ SubscribeLocalEvent(OnTransformToBlobChanged);
+
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnShutdown);
+
+ SubscribeLocalEvent(OnMindAdded);
+ SubscribeLocalEvent(OnMindRemove);
+ }
+
+ private void OnMindAdded(EntityUid uid, BlobCarrierComponent component, MindAddedMessage args)
+ {
+ component.HasMind = true;
+ }
+
+ private void OnMindRemove(EntityUid uid, BlobCarrierComponent component, MindRemovedMessage args)
+ {
+ component.HasMind = false;
+ }
+
+ private void OnTransformToBlobChanged(EntityUid uid, BlobCarrierComponent component, TransformToBlobActionEvent args)
+ {
+ TransformToBlob(uid, component);
+ }
+
+ private void OnStartup(EntityUid uid, BlobCarrierComponent component, ComponentStartup args)
+ {
+ var transformToBlob = "TransformToBlob";
+ _action.AddAction(uid, transformToBlob);
+ var ghostRole = EnsureComp(uid);
+ EnsureComp(uid);
+ ghostRole.RoleName = Loc.GetString("blob-carrier-role-name");
+ ghostRole.RoleDescription = Loc.GetString("blob-carrier-role-desc");
+ ghostRole.RoleRules = Loc.GetString("blob-carrier-role-rules");
+ }
+
+ private void OnShutdown(EntityUid uid, BlobCarrierComponent component, ComponentShutdown args)
+ {
+
+ }
+
+ private void OnMobStateChanged(EntityUid uid, BlobCarrierComponent component, MobStateChangedEvent args)
+ {
+ if (args.NewMobState == MobState.Dead)
+ {
+ TransformToBlob(uid, component);
+ }
+ }
+
+ private void TransformToBlob(EntityUid uid, BlobCarrierComponent carrier)
+ {
+ var xform = Transform(uid);
+ if (!_mapManager.TryGetGrid(xform.GridUid, out var map))
+ return;
+
+ if (_mind.TryGetMind(uid, out var mind, out var mind1) && mind1.Session != null)
+ {
+ var core = Spawn(carrier.CoreBlobPrototype, xform.Coordinates);
+
+ if (!TryComp(core, out var blobCoreComponent))
+ return;
+
+ _blobCoreSystem.CreateBlobObserver(core, mind.Session, blobCoreComponent);
+ }
+ else
+ {
+ Spawn(carrier.CoreBlobGhostRolePrototype, xform.Coordinates);
+ }
+
+ _bodySystem.GibBody(uid);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var blobFactoryQuery = EntityQueryEnumerator();
+ while (blobFactoryQuery.MoveNext(out var ent, out var comp))
+ {
+ if (!comp.HasMind)
+ return;
+
+ comp.TransformationTimer += frameTime;
+
+ if (_gameTiming.CurTime < comp.NextAlert)
+ continue;
+
+ var remainingTime = Math.Round(comp.TransformationDelay - comp.TransformationTimer, 0);
+ _popup.PopupEntity(Loc.GetString("carrier-blob-alert", ("second", remainingTime)), ent, ent, PopupType.LargeCaution);
+
+ comp.NextAlert = _gameTiming.CurTime + TimeSpan.FromSeconds(comp.AlertInterval);
+
+ if (!(comp.TransformationTimer >= comp.TransformationDelay))
+ continue;
+
+ TransformToBlob(ent, comp);
+ }
+ }
+ }
+}
diff --git a/Content.Server/Blob/BlobCoreSystem.cs b/Content.Server/Blob/BlobCoreSystem.cs
new file mode 100644
index 00000000000..ba6ddd03cab
--- /dev/null
+++ b/Content.Server/Blob/BlobCoreSystem.cs
@@ -0,0 +1,340 @@
+using System.Linq;
+using System.Numerics;
+using Content.Server.Chat.Managers;
+using Content.Server.Explosion.Components;
+using Content.Server.Explosion.EntitySystems;
+using Content.Server.Fluids.EntitySystems;
+using Content.Server.GameTicking;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Mind;
+using Content.Server.Objectives;
+using Content.Server.Roles;
+using Content.Shared.Alert;
+using Content.Shared.Blob;
+using Content.Shared.Damage;
+using Content.Shared.Destructible;
+using Content.Shared.FixedPoint;
+using Content.Shared.Popups;
+using Content.Shared.Roles;
+using Content.Shared.Weapons.Melee;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Network;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Player;
+using Robust.Shared.Audio.Systems;
+
+namespace Content.Server.Blob;
+
+public sealed class BlobCoreSystem : EntitySystem
+{
+ [Dependency] private readonly AlertsSystem _alerts = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly MindSystem _mindSystem = default!;
+ [Dependency] private readonly IChatManager _chatManager = default!;
+ [Dependency] private readonly SharedAudioSystem _audioSystem = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly GameTicker _gameTicker = default!;
+ [Dependency] private readonly BlobObserverSystem _blobObserver = default!;
+ [Dependency] private readonly ExplosionSystem _explosionSystem = default!;
+ [Dependency] private readonly DamageableSystem _damageable = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnDestruction);
+ SubscribeLocalEvent(OnDamaged);
+ SubscribeLocalEvent(OnPlayerAttached);
+ }
+
+ private void OnPlayerAttached(EntityUid uid, BlobCoreComponent component, PlayerAttachedEvent args)
+ {
+ var xform = Transform(uid);
+ if (!_mapManager.TryGetGrid(xform.GridUid, out var map))
+ return;
+
+ CreateBlobObserver(uid, args.Player.UserId, component);
+ }
+
+ public bool CreateBlobObserver(EntityUid blobCoreUid, NetUserId userId, BlobCoreComponent? core = null)
+ {
+ var xform = Transform(blobCoreUid);
+
+ if (!Resolve(blobCoreUid, ref core))
+ return false;
+
+ var blobRule = EntityQuery().FirstOrDefault();
+
+ if (blobRule == null)
+ {
+ _gameTicker.StartGameRule("Blob", out var ruleEntity);
+ blobRule = Comp(ruleEntity);
+ }
+
+ var observer = Spawn(core.ObserverBlobPrototype, xform.Coordinates);
+
+ core.Observer = observer;
+
+ if (!TryComp(observer, out var blobObserverComponent))
+ return false;
+
+ blobObserverComponent.Core = blobCoreUid;
+
+ _mindSystem.TryGetMind(userId, out var mind);
+ if (mind == null)
+ return false;
+
+ _mindSystem.TransferTo(mind, observer, ghostCheckOverride: false);
+
+ _alerts.ShowAlert(observer, AlertType.BlobHealth, (short) Math.Clamp(Math.Round(core.CoreBlobTotalHealth.Float() / 10f), 0, 20));
+
+ var antagPrototype = _prototypeManager.Index(core.AntagBlobPrototypeId);
+ var blobRole = new BlobRole(mind, antagPrototype);
+
+ _mindSystem.AddRole(mind, blobRole);
+ SendBlobBriefing(mind);
+
+ blobRule.Blobs.Add(blobRole);
+
+ if (_prototypeManager.TryIndex("BlobCaptureObjective", out var objective)
+ && objective.CanBeAssigned(mind))
+ {
+ _mindSystem.TryAddObjective(blobRole.Mind, objective);
+ }
+
+ if (_mindSystem.TryGetSession(mind, out var session))
+ {
+ _audioSystem.PlayGlobal(core.GreetSoundNotification, session);
+ }
+
+ _blobObserver.UpdateUi(observer, blobObserverComponent);
+
+ return true;
+ }
+
+ private void SendBlobBriefing(Mind.Mind mind)
+ {
+ if (_mindSystem.TryGetSession(mind, out var session))
+ {
+ _chatManager.DispatchServerMessage(session, Loc.GetString("blob-role-greeting"));
+ }
+ }
+
+ private void OnDamaged(EntityUid uid, BlobCoreComponent component, DamageChangedEvent args)
+ {
+ var maxHealth = component.CoreBlobTotalHealth;
+ var currentHealth = maxHealth - args.Damageable.TotalDamage;
+
+ if (component.Observer != null)
+ _alerts.ShowAlert(component.Observer.Value, AlertType.BlobHealth, (short) Math.Clamp(Math.Round(currentHealth.Float() / 10f), 0, 20));
+ }
+
+ private void OnStartup(EntityUid uid, BlobCoreComponent component, ComponentStartup args)
+ {
+ ChangeBlobPoint(uid, 0, component);
+
+ if (TryComp(uid, out var blobTileComponent))
+ {
+ blobTileComponent.Core = uid;
+ blobTileComponent.Color = component.ChemСolors[component.CurrentChem];
+ Dirty(blobTileComponent);
+ }
+
+ component.BlobTiles.Add(uid);
+
+ ChangeChem(uid, component.DefaultChem, component);
+ }
+
+ public void ChangeChem(EntityUid uid, BlobChemType newChem, BlobCoreComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (newChem == component.CurrentChem)
+ return;
+
+ var oldChem = component.CurrentChem;
+ component.CurrentChem = newChem;
+ foreach (var blobTile in component.BlobTiles)
+ {
+ if (!TryComp(blobTile, out var blobTileComponent))
+ continue;
+
+ blobTileComponent.Color = component.ChemСolors[newChem];
+ Dirty(blobTileComponent);
+
+ if (TryComp(blobTile, out var blobFactoryComponent))
+ {
+ if (TryComp(blobFactoryComponent.Blobbernaut, out var blobbernautComponent))
+ {
+ blobbernautComponent.Color = component.ChemСolors[newChem];
+ Dirty(blobbernautComponent);
+
+ if (TryComp(blobFactoryComponent.Blobbernaut, out var meleeWeaponComponent))
+ {
+ var blobbernautDamage = new DamageSpecifier();
+ foreach (var keyValuePair in component.ChemDamageDict[component.CurrentChem].DamageDict)
+ {
+ blobbernautDamage.DamageDict.Add(keyValuePair.Key, keyValuePair.Value * 0.8f);
+ }
+ meleeWeaponComponent.Damage = blobbernautDamage;
+ }
+
+ ChangeBlobEntChem(blobFactoryComponent.Blobbernaut.Value, oldChem, newChem);
+ }
+
+ foreach (var compBlobPod in blobFactoryComponent.BlobPods)
+ {
+ if (TryComp(compBlobPod, out var smokeOnTriggerComponent))
+ {
+ smokeOnTriggerComponent.SmokeColor = component.ChemСolors[newChem];
+ }
+ }
+ }
+
+ ChangeBlobEntChem(blobTile, oldChem, newChem);
+ }
+ }
+
+ private void OnDestruction(EntityUid uid, BlobCoreComponent component, DestructionEventArgs args)
+ {
+ if (component.Observer != null)
+ {
+ QueueDel(component.Observer.Value);
+ }
+
+ foreach (var blobTile in component.BlobTiles)
+ {
+ if (!TryComp(blobTile, out var blobTileComponent))
+ continue;
+ blobTileComponent.Core = null;
+
+ blobTileComponent.Color = Color.White;
+ Dirty(blobTileComponent);
+ }
+ }
+
+ private void ChangeBlobEntChem(EntityUid uid, BlobChemType oldChem, BlobChemType newChem)
+ {
+ var explosionResistance = EnsureComp(uid);
+ if (oldChem == BlobChemType.ExplosiveLattice)
+ {
+ _explosionSystem.SetExplosionResistance(uid, 0.3f, explosionResistance);
+ }
+ switch (newChem)
+ {
+ case BlobChemType.ExplosiveLattice:
+ _damageable.SetDamageModifierSetId(uid, "ExplosiveLatticeBlob");
+ _explosionSystem.SetExplosionResistance(uid, 0f, explosionResistance);
+ break;
+ case BlobChemType.ElectromagneticWeb:
+ _damageable.SetDamageModifierSetId(uid, "ElectromagneticWebBlob");
+ break;
+ case BlobChemType.ReactiveSpines:
+ _damageable.SetDamageModifierSetId(uid, "ReactiveSpinesBlob");
+ break;
+ default:
+ _damageable.SetDamageModifierSetId(uid, "BaseBlob");
+ break;
+ }
+ }
+
+ public bool TransformBlobTile(EntityUid? oldTileUid, EntityUid coreTileUid, string newBlobTileProto,
+ EntityCoordinates coordinates, BlobCoreComponent? blobCore = null, bool returnCost = true,
+ FixedPoint2? transformCost = null)
+ {
+ if (!Resolve(coreTileUid, ref blobCore))
+ return false;
+ if (oldTileUid != null)
+ {
+ QueueDel(oldTileUid.Value);
+ blobCore.BlobTiles.Remove(oldTileUid.Value);
+ }
+ var tileBlob = EntityManager.SpawnEntity(newBlobTileProto, coordinates);
+
+ if (TryComp(tileBlob, out var blobTileComponent))
+ {
+ blobTileComponent.ReturnCost = returnCost;
+ blobTileComponent.Core = coreTileUid;
+ blobTileComponent.Color = blobCore.ChemСolors[blobCore.CurrentChem];
+ Dirty(blobTileComponent);
+
+ var explosionResistance = EnsureComp(tileBlob);
+
+ if (blobCore.CurrentChem == BlobChemType.ExplosiveLattice)
+ {
+ _explosionSystem.SetExplosionResistance(tileBlob, 0f, explosionResistance);
+ }
+ }
+ if (blobCore.Observer != null && transformCost != null)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-spent-resource", ("point", transformCost)),
+ tileBlob,
+ blobCore.Observer.Value,
+ PopupType.LargeCaution);
+ }
+ blobCore.BlobTiles.Add(tileBlob);
+ return true;
+ }
+
+ public bool RemoveBlobTile(EntityUid tileUid, EntityUid coreTileUid, BlobCoreComponent? blobCore = null)
+ {
+ if (!Resolve(coreTileUid, ref blobCore))
+ return false;
+
+ QueueDel(tileUid);
+ blobCore.BlobTiles.Remove(tileUid);
+
+ return true;
+ }
+
+ public bool ChangeBlobPoint(EntityUid uid, FixedPoint2 amount, BlobCoreComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return false;
+
+ component.Points += amount;
+
+ if (component.Observer != null)
+ _alerts.ShowAlert(component.Observer.Value, AlertType.BlobResource, (short) Math.Clamp(Math.Round(component.Points.Float() / 10f), 0, 16));
+
+ return true;
+ }
+
+ public bool TryUseAbility(EntityUid uid, EntityUid coreUid, BlobCoreComponent component, FixedPoint2 abilityCost)
+ {
+ if (component.Points < abilityCost)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-not-enough-resources"), uid, uid, PopupType.Large);
+ return false;
+ }
+
+ ChangeBlobPoint(coreUid, -abilityCost, component);
+
+ return true;
+ }
+
+ public bool CheckNearNode(EntityUid observer, EntityCoordinates coords, MapGridComponent grid, BlobCoreComponent core)
+ {
+ var radius = 3f;
+
+ var innerTiles = grid.GetLocalTilesIntersecting(
+ new Box2(coords.Position + new Vector2(-radius, -radius), coords.Position + new Vector2(radius, radius)), false).ToArray();
+
+ foreach (var tileRef in innerTiles)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (HasComp(ent) || HasComp(ent))
+ return true;
+ }
+ }
+
+ _popup.PopupCoordinates(Loc.GetString("blob-target-nearby-not-node"), coords, observer, PopupType.Large);
+ return false;
+ }
+}
diff --git a/Content.Server/Blob/BlobFactoryComponent.cs b/Content.Server/Blob/BlobFactoryComponent.cs
new file mode 100644
index 00000000000..8c19d819b3f
--- /dev/null
+++ b/Content.Server/Blob/BlobFactoryComponent.cs
@@ -0,0 +1,32 @@
+namespace Content.Server.Blob;
+
+[RegisterComponent]
+public sealed class BlobFactoryComponent : Component
+{
+ [ViewVariables(VVAccess.ReadOnly)]
+ public float SpawnedCount = 0;
+
+ [DataField("spawnLimit"), ViewVariables(VVAccess.ReadWrite)]
+ public float SpawnLimit = 3;
+
+ [DataField("spawnRate"), ViewVariables(VVAccess.ReadWrite)]
+ public float SpawnRate = 10;
+
+ [DataField("blobSporeId"), ViewVariables(VVAccess.ReadWrite)]
+ public string Pod = "MobBlobPod";
+
+ [DataField("blobbernautId"), ViewVariables(VVAccess.ReadWrite)]
+ public string BlobbernautId = "MobBlobBlobbernaut";
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public EntityUid? Blobbernaut = default!;
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public List BlobPods = new ();
+
+ public TimeSpan NextSpawn = TimeSpan.Zero;
+}
+
+public sealed class ProduceBlobbernautEvent : EntityEventArgs
+{
+}
diff --git a/Content.Server/Blob/BlobFactorySystem.cs b/Content.Server/Blob/BlobFactorySystem.cs
new file mode 100644
index 00000000000..56cf444e7f7
--- /dev/null
+++ b/Content.Server/Blob/BlobFactorySystem.cs
@@ -0,0 +1,96 @@
+using Content.Server.Blob.NPC.BlobPod;
+using Content.Server.Fluids.EntitySystems;
+using Content.Shared.Blob;
+using Content.Shared.Damage;
+using Content.Shared.Destructible;
+using Content.Shared.Weapons.Melee;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Blob;
+
+public sealed class BlobFactorySystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnPulsed);
+ SubscribeLocalEvent(OnProduceBlobbernaut);
+ SubscribeLocalEvent(OnDestruction);
+ }
+
+ private void OnStartup(EntityUid uid, BlobFactoryComponent observerComponent, ComponentStartup args)
+ {
+
+ }
+
+ private void OnDestruction(EntityUid uid, BlobFactoryComponent component, DestructionEventArgs args)
+ {
+ if (TryComp(component.Blobbernaut, out var blobbernautComponent))
+ {
+ blobbernautComponent.Factory = null;
+ }
+ }
+
+ private void OnProduceBlobbernaut(EntityUid uid, BlobFactoryComponent component, ProduceBlobbernautEvent args)
+ {
+ if (component.Blobbernaut != null)
+ return;
+
+ if (!TryComp(uid, out var blobTileComponent) || blobTileComponent.Core == null)
+ return;
+
+ if (!TryComp(blobTileComponent.Core, out var blobCoreComponent))
+ return;
+
+ var xform = Transform(uid);
+
+ var blobbernaut = Spawn(component.BlobbernautId, xform.Coordinates);
+
+ component.Blobbernaut = blobbernaut;
+ if (TryComp(blobbernaut, out var blobbernautComponent))
+ {
+ blobbernautComponent.Factory = uid;
+ blobbernautComponent.Color = blobCoreComponent.ChemСolors[blobCoreComponent.CurrentChem];
+ Dirty(blobbernautComponent);
+ }
+ if (TryComp(blobbernaut, out var meleeWeaponComponent))
+ {
+ var blobbernautDamage = new DamageSpecifier();
+ foreach (var keyValuePair in blobCoreComponent.ChemDamageDict[blobCoreComponent.CurrentChem].DamageDict)
+ {
+ blobbernautDamage.DamageDict.Add(keyValuePair.Key, keyValuePair.Value * 0.8f);
+ }
+ meleeWeaponComponent.Damage = blobbernautDamage;
+ }
+ }
+
+ private void OnPulsed(EntityUid uid, BlobFactoryComponent component, BlobTileGetPulseEvent args)
+ {
+ if (!TryComp(uid, out var blobTileComponent) || blobTileComponent.Core == null)
+ return;
+
+ if (!TryComp(blobTileComponent.Core, out var blobCoreComponent))
+ return;
+
+ if (component.SpawnedCount >= component.SpawnLimit)
+ return;
+
+ if (_gameTiming.CurTime < component.NextSpawn)
+ return;
+
+ var xform = Transform(uid);
+ var pod = Spawn(component.Pod, xform.Coordinates);
+ component.BlobPods.Add(pod);
+ var blobPod = EnsureComp(pod);
+ blobPod.Core = blobTileComponent.Core.Value;
+ var smokeOnTrigger = EnsureComp(pod);
+ smokeOnTrigger.SmokeColor = blobCoreComponent.ChemСolors[blobCoreComponent.CurrentChem];
+ component.SpawnedCount += 1;
+ component.NextSpawn = _gameTiming.CurTime + TimeSpan.FromSeconds(component.SpawnRate);
+ }
+
+}
diff --git a/Content.Server/Blob/BlobMobComponent.cs b/Content.Server/Blob/BlobMobComponent.cs
new file mode 100644
index 00000000000..0858bc6b691
--- /dev/null
+++ b/Content.Server/Blob/BlobMobComponent.cs
@@ -0,0 +1,22 @@
+using Content.Shared.Damage;
+using Content.Shared.FixedPoint;
+
+namespace Content.Server.Blob;
+
+[RegisterComponent]
+public sealed class BlobMobComponent : Component
+{
+ [ViewVariables(VVAccess.ReadOnly), DataField("healthOfPulse")]
+ public DamageSpecifier HealthOfPulse = new()
+ {
+ DamageDict = new Dictionary
+ {
+ { "Blunt", -4 },
+ { "Slash", -4 },
+ { "Piercing", -4 },
+ { "Heat", -4 },
+ { "Cold", -4 },
+ { "Shock", -4 },
+ }
+ };
+}
diff --git a/Content.Server/Blob/BlobMobSystem.cs b/Content.Server/Blob/BlobMobSystem.cs
new file mode 100644
index 00000000000..500e07433e7
--- /dev/null
+++ b/Content.Server/Blob/BlobMobSystem.cs
@@ -0,0 +1,36 @@
+using Content.Server.Popups;
+using Content.Shared.Damage;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Popups;
+
+namespace Content.Server.Blob
+{
+ public sealed class BlobMobSystem : EntitySystem
+ {
+ [Dependency] private readonly DamageableSystem _damageableSystem = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnPulsed);
+ SubscribeLocalEvent(OnBlobAttackAttempt);
+ }
+
+ private void OnPulsed(EntityUid uid, BlobMobComponent component, BlobMobGetPulseEvent args)
+ {
+ _damageableSystem.TryChangeDamage(uid, component.HealthOfPulse);
+ }
+
+ private void OnBlobAttackAttempt(EntityUid uid, BlobMobComponent component, AttackAttemptEvent args)
+ {
+ if (args.Cancelled || !HasComp(args.Target) && !HasComp(args.Target))
+ return;
+
+ // TODO: Move this to shared
+ _popupSystem.PopupCursor(Loc.GetString("blob-mob-attack-blob"), uid, PopupType.Large);
+ args.Cancel();
+ }
+ }
+}
diff --git a/Content.Server/Blob/BlobNodeComponent.cs b/Content.Server/Blob/BlobNodeComponent.cs
new file mode 100644
index 00000000000..0448a43a4b0
--- /dev/null
+++ b/Content.Server/Blob/BlobNodeComponent.cs
@@ -0,0 +1,24 @@
+using Content.Shared.FixedPoint;
+
+namespace Content.Server.Blob;
+
+[RegisterComponent]
+public sealed class BlobNodeComponent : Component
+{
+ [ViewVariables(VVAccess.ReadWrite), DataField("pulseFrequency")]
+ public float PulseFrequency = 4;
+
+ [ViewVariables(VVAccess.ReadWrite), DataField("pulseRadius")]
+ public float PulseRadius = 3f;
+
+ public TimeSpan NextPulse = TimeSpan.Zero;
+}
+
+public sealed class BlobTileGetPulseEvent : EntityEventArgs
+{
+ public bool Explain { get; set; }
+}
+
+public sealed class BlobMobGetPulseEvent : EntityEventArgs
+{
+}
diff --git a/Content.Server/Blob/BlobNodeSystem.cs b/Content.Server/Blob/BlobNodeSystem.cs
new file mode 100644
index 00000000000..e28a583befd
--- /dev/null
+++ b/Content.Server/Blob/BlobNodeSystem.cs
@@ -0,0 +1,93 @@
+using System.Linq;
+using System.Numerics;
+using Robust.Shared.Map;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Blob;
+
+public sealed class BlobNodeSystem : EntitySystem
+{
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly IMapManager _map = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnStartup);
+ }
+
+ private void OnStartup(EntityUid uid, BlobNodeComponent component, ComponentStartup args)
+ {
+
+ }
+
+ private void Pulse(EntityUid uid, BlobNodeComponent component)
+ {
+ var xform = Transform(uid);
+
+ var radius = component.PulseRadius;
+
+ var localPos = xform.Coordinates.Position;
+
+ if (!_map.TryGetGrid(xform.GridUid, out var grid))
+ {
+ return;
+ }
+
+ if (!TryComp(uid, out var blobTileComponent) || blobTileComponent.Core == null)
+ return;
+
+ var innerTiles = grid.GetLocalTilesIntersecting(
+ new Box2(localPos + new Vector2(-radius, -radius), localPos + new Vector2(radius, radius)), false).ToArray();
+
+ _random.Shuffle(innerTiles);
+
+ var explain = true;
+ foreach (var tileRef in innerTiles)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!HasComp(ent))
+ continue;
+
+ var ev = new BlobTileGetPulseEvent
+ {
+ Explain = explain
+ };
+ RaiseLocalEvent(ent, ev);
+ explain = false;
+ }
+ }
+
+ foreach (var lookupUid in _lookup.GetEntitiesInRange(xform.Coordinates, radius))
+ {
+ if (!HasComp(lookupUid))
+ continue;
+ var ev = new BlobMobGetPulseEvent();
+ RaiseLocalEvent(lookupUid, ev);
+ }
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var blobFactoryQuery = EntityQueryEnumerator();
+ while (blobFactoryQuery.MoveNext(out var ent, out var comp))
+ {
+ if (_gameTiming.CurTime < comp.NextPulse)
+ return;
+
+ if (TryComp(ent, out var blobTileComponent) && blobTileComponent.Core != null)
+ {
+ Pulse(ent, comp);
+ }
+
+ comp.NextPulse = _gameTiming.CurTime + TimeSpan.FromSeconds(comp.PulseFrequency);
+ }
+ }
+}
diff --git a/Content.Server/Blob/BlobObserverSystem.cs b/Content.Server/Blob/BlobObserverSystem.cs
new file mode 100644
index 00000000000..dfafc7944d6
--- /dev/null
+++ b/Content.Server/Blob/BlobObserverSystem.cs
@@ -0,0 +1,868 @@
+using System.Linq;
+using System.Numerics;
+using Content.Server.Actions;
+using Content.Server.Atmos.Components;
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.Destructible;
+using Content.Server.Emp;
+using Content.Server.Explosion.EntitySystems;
+using Content.Shared.ActionBlocker;
+using Content.Shared.Actions.ActionTypes;
+using Content.Shared.Blob;
+using Content.Shared.Coordinates.Helpers;
+using Content.Shared.Damage;
+using Content.Shared.Interaction;
+using Content.Shared.Item;
+using Content.Shared.Maps;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Popups;
+using Content.Shared.Random.Helpers;
+using Content.Shared.SubFloor;
+using Robust.Server.GameObjects;
+using Robust.Server.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+using Robust.Shared.Player;
+
+namespace Content.Server.Blob;
+
+public sealed class BlobObserverSystem : SharedBlobObserverSystem
+{
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly ActionsSystem _action = default!;
+ [Dependency] private readonly IMapManager _map = default!;
+ [Dependency] private readonly DamageableSystem _damageableSystem = default!;
+ [Dependency] private readonly BlobCoreSystem _blobCoreSystem = default!;
+ [Dependency] private readonly AudioSystem _audioSystem = default!;
+ [Dependency] private readonly ITileDefinitionManager _tileDefinitionManager = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly ActionBlockerSystem _blocker = default!;
+ [Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
+ [Dependency] private readonly ExplosionSystem _explosionSystem = default!;
+ [Dependency] private readonly FlammableSystem _flammable = default!;
+ [Dependency] private readonly EmpSystem _empSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnCreateFactory);
+ SubscribeLocalEvent(OnCreateResource);
+ SubscribeLocalEvent(OnCreateNode);
+ SubscribeLocalEvent(OnCreateBlobbernaut);
+ SubscribeLocalEvent(OnBlobToCore);
+ SubscribeLocalEvent(OnBlobToNode);
+ SubscribeLocalEvent(OnBlobHelp);
+ SubscribeLocalEvent(OnBlobSwapChem);
+ SubscribeLocalEvent(OnInteract);
+ SubscribeLocalEvent(OnSwapCore);
+ SubscribeLocalEvent(OnSplitCore);
+ SubscribeLocalEvent(OnMoveEvent);
+ SubscribeLocalEvent(GetState);
+ SubscribeLocalEvent(OnChemSelected);
+ }
+
+ private void OnBlobSwapChem(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobSwapChemActionEvent args)
+ {
+ TryOpenUi(uid, args.Performer, observerComponent);
+ args.Handled = true;
+ }
+
+ private void OnChemSelected(EntityUid uid, BlobObserverComponent component, BlobChemSwapPrototypeSelectedMessage args)
+ {
+ if (component.Core == null || !TryComp(component.Core.Value, out var blobCoreComponent))
+ return;
+
+ if (component.SelectedChemId == args.SelectedId)
+ return;
+
+ if (!_blobCoreSystem.TryUseAbility(uid, component.Core.Value, blobCoreComponent,
+ blobCoreComponent.SwapChemCost))
+ return;
+
+ ChangeChem(uid, args.SelectedId, component);
+ }
+
+ private void ChangeChem(EntityUid uid, BlobChemType newChem, BlobObserverComponent component)
+ {
+ if (component.Core == null || !TryComp(component.Core.Value, out var blobCoreComponent))
+ return;
+ component.SelectedChemId = newChem;
+ _blobCoreSystem.ChangeChem(component.Core.Value, newChem, blobCoreComponent);
+
+ _popup.PopupEntity(Loc.GetString("blob-spent-resource", ("point", blobCoreComponent.SwapChemCost)),
+ uid,
+ uid,
+ PopupType.LargeCaution);
+
+ UpdateUi(uid, component);
+ }
+
+ private void GetState(EntityUid uid, BlobObserverComponent component, ref ComponentGetState args)
+ {
+ args.State = new BlobChemSwapComponentState
+ {
+ SelectedChem = component.SelectedChemId
+ };
+ }
+
+ private void TryOpenUi(EntityUid uid, EntityUid user, BlobObserverComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (!TryComp(user, out ActorComponent? actor))
+ return;
+
+ _uiSystem.TryToggleUi(uid, BlobChemSwapUiKey.Key, actor.PlayerSession);
+ }
+
+ public void UpdateUi(EntityUid uid, BlobObserverComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (component.Core == null || !TryComp(component.Core.Value, out var blobCoreComponent))
+ return;
+
+ var state = new BlobChemSwapBoundUserInterfaceState(blobCoreComponent.ChemСolors, component.SelectedChemId);
+
+ _uiSystem.TrySetUiState(uid, BlobChemSwapUiKey.Key, state);
+ }
+
+ // TODO: This is very bad, but it is clearly better than invisible walls, let someone do better.
+ private void OnMoveEvent(EntityUid uid, BlobObserverComponent observerComponent, ref MoveEvent args)
+ {
+ if (observerComponent.IsProcessingMoveEvent)
+ return;
+
+ observerComponent.IsProcessingMoveEvent = true;
+
+ if (observerComponent.Core == null)
+ {
+ observerComponent.IsProcessingMoveEvent = false;
+ return;
+ }
+
+ if (Deleted(observerComponent.Core.Value) ||
+ !TryComp(observerComponent.Core.Value, out var xform))
+ {
+ return;
+ }
+
+ var corePos = xform.Coordinates;
+
+ var (nearestEntityUid, nearestDistance) = CalculateNearestBlobTileDistance(args.NewPosition);
+
+ if (nearestEntityUid == null)
+ return;
+
+ if (nearestDistance > 5f)
+ {
+ _transform.SetCoordinates(uid, corePos);
+
+ observerComponent.IsProcessingMoveEvent = false;
+ return;
+ }
+
+ if (nearestDistance > 3f)
+ {
+ observerComponent.CanMove = true;
+ _blocker.UpdateCanMove(uid);
+ var direction = (Transform(nearestEntityUid.Value).Coordinates.Position - args.NewPosition.Position);
+ var newPosition = args.NewPosition.Offset(direction * 0.1f);
+
+ _transform.SetCoordinates(uid, newPosition);
+ }
+
+ observerComponent.IsProcessingMoveEvent = false;
+ }
+
+ private (EntityUid? nearestEntityUid, float nearestDistance) CalculateNearestBlobTileDistance(EntityCoordinates position)
+ {
+ var nearestDistance = float.MaxValue;
+ EntityUid? nearestEntityUid = null;
+
+ foreach (var lookupUid in _lookup.GetEntitiesInRange(position, 5f))
+ {
+ if (!HasComp(lookupUid))
+ continue;
+ var tileCords = Transform(lookupUid).Coordinates;
+ var distance = Vector2.Distance(position.Position, tileCords.Position);
+
+ if (!(distance < nearestDistance))
+ continue;
+ nearestDistance = distance;
+ nearestEntityUid = lookupUid;
+ }
+
+ return (nearestEntityUid, nearestDistance);
+ }
+
+ private void OnBlobHelp(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobHelpActionEvent args)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-help"), uid, uid, PopupType.Large);
+ args.Handled = true;
+ }
+
+ private void OnSplitCore(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobSplitCoreActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null || !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ if (!blobCoreComponent.CanSplit)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-cant-split"), uid, uid, PopupType.Large);
+ return;
+ }
+
+ var gridUid = args.Target.GetGridUid(EntityManager);
+
+ if (!_map.TryGetGrid(gridUid, out var grid))
+ {
+ return;
+ }
+
+ var centerTile = grid.GetLocalTilesIntersecting(
+ new Box2(args.Target.Position, args.Target.Position)).ToArray();
+
+ EntityUid? blobTile = null;
+
+ foreach (var tileref in centerTile)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileref.GridIndices))
+ {
+ if (!TryComp(ent, out var blobTileComponent))
+ continue;
+ blobTile = ent;
+ break;
+ }
+ }
+
+ if (blobTile == null || !TryComp(blobTile, out var blobNodeComponent))
+ {
+ _popup.PopupEntity(Loc.GetString("blob-target-node-blob-invalid"), uid, uid, PopupType.Large);
+ args.Handled = true;
+ return;
+ }
+
+ if (!_blobCoreSystem.TryUseAbility(uid, observerComponent.Core.Value, blobCoreComponent,
+ blobCoreComponent.SplitCoreCost))
+ {
+ args.Handled = true;
+ return;
+ }
+
+ QueueDel(blobTile.Value);
+ var newCore = EntityManager.SpawnEntity(blobCoreComponent.CoreBlobTile, args.Target);
+ blobCoreComponent.CanSplit = false;
+ if (TryComp(newCore, out var newBlobCoreComponent))
+ newBlobCoreComponent.CanSplit = false;
+
+ args.Handled = true;
+ }
+
+
+ private void OnSwapCore(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobSwapCoreActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null || !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ var gridUid = args.Target.GetGridUid(EntityManager);
+
+ if (!_map.TryGetGrid(gridUid, out var grid))
+ {
+ return;
+ }
+
+ var centerTile = grid.GetLocalTilesIntersecting(
+ new Box2(args.Target.Position, args.Target.Position)).ToArray();
+
+ EntityUid? blobTile = null;
+
+ foreach (var tileRef in centerTile)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!TryComp(ent, out var blobTileComponent))
+ continue;
+ blobTile = ent;
+ break;
+ }
+ }
+
+ if (blobTile == null || !TryComp(blobTile, out var blobNodeComponent))
+ {
+ _popup.PopupEntity(Loc.GetString("blob-target-node-blob-invalid"), uid, uid, PopupType.Large);
+ args.Handled = true;
+ return;
+ }
+
+ if (!_blobCoreSystem.TryUseAbility(uid, observerComponent.Core.Value, blobCoreComponent,
+ blobCoreComponent.SwapCoreCost))
+ {
+ args.Handled = true;
+ return;
+ }
+
+ var nodePos = Transform(blobTile.Value).Coordinates;
+ var corePos = Transform(observerComponent.Core.Value).Coordinates;
+ _transform.SetCoordinates(observerComponent.Core.Value, nodePos.SnapToGrid());
+ _transform.SetCoordinates(blobTile.Value, corePos.SnapToGrid());
+ var xformCore = Transform(observerComponent.Core.Value);
+ if (!xformCore.Anchored)
+ {
+ _transform.AnchorEntity(observerComponent.Core.Value, xformCore);
+ }
+ var xformNode = Transform(blobTile.Value);
+ if (!xformNode.Anchored)
+ {
+ _transform.AnchorEntity(blobTile.Value, xformNode);
+ }
+ args.Handled = true;
+ }
+
+ private void OnBlobToNode(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobToNodeActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null || !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ var blobNodes = new List();
+
+ var blobNodeQuery = EntityQueryEnumerator();
+ while (blobNodeQuery.MoveNext(out var ent, out var node, out var tile))
+ {
+ if (tile.Core == observerComponent.Core.Value && !HasComp(ent))
+ blobNodes.Add(ent);
+ }
+
+ if (blobNodes.Count == 0)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-not-have-nodes"), uid, uid, PopupType.Large);
+ args.Handled = true;
+ return;
+ }
+
+ _transform.SetCoordinates(uid, Transform(_random.Pick(blobNodes)).Coordinates);
+ args.Handled = true;
+ }
+
+ private void OnCreateBlobbernaut(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobCreateBlobbernautActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null ||
+ !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ var gridUid = args.Target.GetGridUid(EntityManager);
+
+ if (!_map.TryGetGrid(gridUid, out var grid))
+ {
+ return;
+ }
+
+ var centerTile = grid.GetLocalTilesIntersecting(
+ new Box2(args.Target.Position, args.Target.Position)).ToArray();
+
+ EntityUid? blobTile = null;
+
+ foreach (var tileRef in centerTile)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!HasComp(ent))
+ continue;
+ blobTile = ent;
+ break;
+ }
+ }
+
+ if (blobTile == null || !TryComp(blobTile, out var blobFactoryComponent))
+ {
+ _popup.PopupEntity(Loc.GetString("blob-target-factory-blob-invalid"), uid, uid, PopupType.LargeCaution);
+ return;
+ }
+
+ if (blobFactoryComponent.Blobbernaut != null)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-target-already-produce-blobbernaut"), uid, uid, PopupType.LargeCaution);
+ return;
+ }
+
+ if (!_blobCoreSystem.TryUseAbility(uid, observerComponent.Core.Value, blobCoreComponent, blobCoreComponent.BlobbernautCost))
+ return;
+
+ var ev = new ProduceBlobbernautEvent();
+ RaiseLocalEvent(blobTile.Value, ev);
+
+ _popup.PopupEntity(Loc.GetString("blob-spent-resource", ("point", blobCoreComponent.BlobbernautCost)),
+ blobTile.Value,
+ uid,
+ PopupType.LargeCaution);
+
+ args.Handled = true;
+ }
+
+ private void OnBlobToCore(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobToCoreActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null ||
+ !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ _transform.SetCoordinates(uid, Transform(observerComponent.Core.Value).Coordinates);
+ }
+
+ private void OnCreateNode(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobCreateNodeActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null ||
+ !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ var gridUid = args.Target.GetGridUid(EntityManager);
+
+ if (!_map.TryGetGrid(gridUid, out var grid))
+ {
+ return;
+ }
+
+ var centerTile = grid.GetLocalTilesIntersecting(
+ new Box2(args.Target.Position, args.Target.Position)).ToArray();
+
+ var blobTileType = BlobTileType.None;
+ EntityUid? blobTile = null;
+
+ foreach (var tileRef in centerTile)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!TryComp(ent, out var blobTileComponent))
+ continue;
+ blobTileType = blobTileComponent.BlobTileType;
+ blobTile = ent;
+ break;
+ }
+ }
+
+ if (blobTileType is not BlobTileType.Normal ||
+ blobTile == null)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-target-normal-blob-invalid"), uid, uid, PopupType.Large);
+ return;
+ }
+
+ var xform = Transform(blobTile.Value);
+
+ var localPos = xform.Coordinates.Position;
+
+ var radius = blobCoreComponent.NodeRadiusLimit;
+
+ var innerTiles = grid.GetLocalTilesIntersecting(
+ new Box2(localPos + new Vector2(-radius, -radius), localPos + new Vector2(radius, radius)), false).ToArray();
+
+ foreach (var tileRef in innerTiles)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!HasComp(ent))
+ continue;
+ _popup.PopupEntity(Loc.GetString("blob-target-close-to-node"), uid, uid, PopupType.Large);
+ return;
+ }
+ }
+
+ if (!_blobCoreSystem.TryUseAbility(uid, observerComponent.Core.Value, blobCoreComponent, blobCoreComponent.NodeBlobCost))
+ return;
+
+ if (!_blobCoreSystem.TransformBlobTile(blobTile.Value,
+ observerComponent.Core.Value,
+ blobCoreComponent.NodeBlobTile,
+ args.Target,
+ blobCoreComponent,
+ transformCost: blobCoreComponent.NodeBlobCost))
+ return;
+
+ args.Handled = true;
+ }
+
+ private void OnCreateResource(EntityUid uid, BlobObserverComponent observerComponent,
+ BlobCreateResourceActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null ||
+ !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ var gridUid = args.Target.GetGridUid(EntityManager);
+
+ if (!_map.TryGetGrid(gridUid, out var grid))
+ {
+ return;
+ }
+
+ var centerTile = grid.GetLocalTilesIntersecting(
+ new Box2(args.Target.Position, args.Target.Position)).ToArray();
+
+ var blobTileType = BlobTileType.None;
+ EntityUid? blobTile = null;
+
+ foreach (var tileref in centerTile)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileref.GridIndices))
+ {
+ if (!TryComp(ent, out var blobTileComponent))
+ continue;
+ blobTileType = blobTileComponent.BlobTileType;
+ blobTile = ent;
+ break;
+ }
+ }
+
+ if (blobTileType is not BlobTileType.Normal ||
+ blobTile == null)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-target-normal-blob-invalid"), uid, uid, PopupType.Large);
+ return;
+ }
+
+ var xform = Transform(blobTile.Value);
+
+ var localPos = xform.Coordinates.Position;
+
+ var radius = blobCoreComponent.ResourceRadiusLimit;
+
+ var innerTiles = grid.GetLocalTilesIntersecting(
+ new Box2(localPos + new Vector2(-radius, -radius), localPos + new Vector2(radius, radius)), false).ToArray();
+
+ foreach (var tileRef in innerTiles)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!HasComp(ent) || HasComp(ent))
+ continue;
+ _popup.PopupEntity(Loc.GetString("blob-target-close-to-resource"), uid, uid, PopupType.Large);
+ return;
+ }
+ }
+
+ if (!_blobCoreSystem.CheckNearNode(uid, xform.Coordinates, grid, blobCoreComponent))
+ return;
+
+ if (!_blobCoreSystem.TryUseAbility(uid,
+ observerComponent.Core.Value,
+ blobCoreComponent,
+ blobCoreComponent.ResourceBlobCost))
+ return;
+
+ if (!_blobCoreSystem.TransformBlobTile(blobTile.Value,
+ observerComponent.Core.Value,
+ blobCoreComponent.ResourceBlobTile,
+ args.Target,
+ blobCoreComponent,
+ transformCost: blobCoreComponent.ResourceBlobCost))
+ return;
+
+ args.Handled = true;
+ }
+
+ private void OnInteract(EntityUid uid, BlobObserverComponent observerComponent, InteractNoHandEvent args)
+ {
+ if (args.Target == args.User)
+ return;
+
+ if (observerComponent.Core == null ||
+ !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ var location = args.ClickLocation;
+ if (!location.IsValid(EntityManager))
+ return;
+
+ var gridId = location.GetGridUid(EntityManager);
+ if (!HasComp(gridId))
+ {
+ location = location.AlignWithClosestGridTile();
+ gridId = location.GetGridUid(EntityManager);
+ if (!HasComp(gridId))
+ return;
+ }
+
+ if (!_map.TryGetGrid(gridId, out var grid))
+ {
+ return;
+ }
+
+ if (args.Target != null &&
+ !HasComp(args.Target.Value) &&
+ !HasComp(args.Target.Value))
+ {
+ var target = args.Target.Value;
+
+ // Check if the target is adjacent to a tile with BlobCellComponent horizontally or vertically
+ var xform = Transform(target);
+ var mobTile = grid.GetTileRef(xform.Coordinates);
+
+ var mobAdjacentTiles = new[]
+ {
+ mobTile.GridIndices.Offset(Direction.East),
+ mobTile.GridIndices.Offset(Direction.West),
+ mobTile.GridIndices.Offset(Direction.North),
+ mobTile.GridIndices.Offset(Direction.South)
+ };
+ if (mobAdjacentTiles.Any(indices => grid.GetAnchoredEntities(indices).Any(ent => HasComp(ent))))
+ {
+ if (HasComp(target) && !HasComp(target)&& !HasComp(target))
+ {
+ if (_blobCoreSystem.TryUseAbility(uid, observerComponent.Core.Value, blobCoreComponent, blobCoreComponent.AttackCost))
+ {
+ if (_gameTiming.CurTime < blobCoreComponent.NextAction)
+ return;
+ if (blobCoreComponent.Observer != null)
+ {
+ _popup.PopupCoordinates(Loc.GetString("blob-spent-resource", ("point", blobCoreComponent.AttackCost)),
+ args.ClickLocation,
+ blobCoreComponent.Observer.Value,
+ PopupType.LargeCaution);
+ }
+ _damageableSystem.TryChangeDamage(target, blobCoreComponent.ChemDamageDict[blobCoreComponent.CurrentChem]);
+
+ if (blobCoreComponent.CurrentChem == BlobChemType.ExplosiveLattice)
+ {
+ _explosionSystem.QueueExplosion(target, blobCoreComponent.BlobExplosive, 4, 1, 6, maxTileBreak: 0);
+ }
+
+ if (blobCoreComponent.CurrentChem == BlobChemType.ElectromagneticWeb)
+ {
+ if (_random.Prob(0.2f))
+ _empSystem.EmpPulse(xform.MapPosition, 3f, 50f, 3f);
+ }
+
+ if (blobCoreComponent.CurrentChem == BlobChemType.BlazingOil)
+ {
+ if (TryComp(target, out var flammable))
+ {
+ flammable.FireStacks += 2;
+ _flammable.Ignite(target, uid, flammable);
+ }
+ }
+ blobCoreComponent.NextAction =
+ _gameTiming.CurTime + TimeSpan.FromSeconds(blobCoreComponent.AttackRate);
+ _audioSystem.PlayPvs(blobCoreComponent.AttackSound, uid, AudioParams.Default);
+ return;
+ }
+ }
+ }
+ }
+
+ var centerTile = grid.GetLocalTilesIntersecting(
+ new Box2(location.Position, location.Position), false).ToArray();
+
+ var targetTileEmplty = false;
+ foreach (var tileRef in centerTile)
+ {
+ if (tileRef.Tile.IsEmpty)
+ {
+ targetTileEmplty = true;
+ }
+
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (HasComp(ent))
+ return;
+ }
+
+ foreach (var entityUid in _lookup.GetEntitiesIntersecting(tileRef.GridIndices.ToEntityCoordinates(gridId.Value, _map).ToMap(EntityManager)))
+ {
+ if (HasComp(entityUid) && !HasComp(entityUid))
+ return;
+ }
+ }
+
+ var targetTile = grid.GetTileRef(location);
+
+ var adjacentTiles = new[]
+ {
+ targetTile.GridIndices.Offset(Direction.East),
+ targetTile.GridIndices.Offset(Direction.West),
+ targetTile.GridIndices.Offset(Direction.North),
+ targetTile.GridIndices.Offset(Direction.South)
+ };
+
+ if (!adjacentTiles.Any(indices =>
+ grid.GetAnchoredEntities(indices).Any(ent => HasComp(ent))))
+ return;
+ var cost = blobCoreComponent.NormalBlobCost;
+ if (targetTileEmplty)
+ {
+ cost *= 2;
+ }
+
+ if (!_blobCoreSystem.TryUseAbility(uid, observerComponent.Core.Value, blobCoreComponent, cost))
+ return;
+
+ if (targetTileEmplty)
+ {
+ var plating = _tileDefinitionManager["Plating"];
+ var platingTile = new Tile(plating.TileId);
+ grid.SetTile(location, platingTile);
+ }
+
+ _blobCoreSystem.TransformBlobTile(null,
+ observerComponent.Core.Value,
+ blobCoreComponent.NormalBlobTile,
+ location,
+ blobCoreComponent,
+ transformCost: cost);
+ }
+
+ private void OnStartup(EntityUid uid, BlobObserverComponent component, ComponentStartup args)
+ {
+ var helpBlob = new InstantAction(
+ _proto.Index("HelpBlob"));
+ _action.AddAction(uid, helpBlob, null);
+ var swapBlobChem = new InstantAction(
+ _proto.Index("SwapBlobChem"));
+ _action.AddAction(uid, swapBlobChem, null);
+ var teleportBlobToCore = new InstantAction(
+ _proto.Index("TeleportBlobToCore"));
+ _action.AddAction(uid, teleportBlobToCore, null);
+ var teleportBlobToNode = new InstantAction(
+ _proto.Index("TeleportBlobToNode"));
+ _action.AddAction(uid, teleportBlobToNode, null);
+ var createBlobFactory = new WorldTargetAction(
+ _proto.Index("CreateBlobFactory"));
+ _action.AddAction(uid, createBlobFactory, null);
+ var createBlobResource = new WorldTargetAction(
+ _proto.Index("CreateBlobResource"));
+ _action.AddAction(uid, createBlobResource, null);
+ var createBlobNode = new WorldTargetAction(
+ _proto.Index("CreateBlobNode"));
+ _action.AddAction(uid, createBlobNode, null);
+ var createBlobbernaut = new WorldTargetAction(
+ _proto.Index("CreateBlobbernaut"));
+ _action.AddAction(uid, createBlobbernaut, null);
+ var splitBlobCore = new WorldTargetAction(
+ _proto.Index("SplitBlobCore"));
+ _action.AddAction(uid, splitBlobCore, null);
+ var swapBlobCore = new WorldTargetAction(
+ _proto.Index("SwapBlobCore"));
+ _action.AddAction(uid, swapBlobCore, null);
+ }
+
+ private void OnCreateFactory(EntityUid uid, BlobObserverComponent observerComponent, BlobCreateFactoryActionEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (observerComponent.Core == null ||
+ !TryComp(observerComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ var gridUid = args.Target.GetGridUid(EntityManager);
+
+ if (!_map.TryGetGrid(gridUid, out var grid))
+ {
+ return;
+ }
+
+ var centerTile = grid.GetLocalTilesIntersecting(
+ new Box2(args.Target.Position, args.Target.Position)).ToArray();
+
+ var blobTileType = BlobTileType.None;
+ EntityUid? blobTile = null;
+
+ foreach (var tileRef in centerTile)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!TryComp(ent, out var blobTileComponent))
+ continue;
+ blobTileType = blobTileComponent.BlobTileType;
+ blobTile = ent;
+ break;
+ }
+ }
+
+ if (blobTileType is not BlobTileType.Normal ||
+ blobTile == null)
+ {
+ _popup.PopupEntity(Loc.GetString("blob-target-normal-blob-invalid"), uid, uid, PopupType.Large);
+ return;
+ }
+
+ var xform = Transform(blobTile.Value);
+
+ var localPos = xform.Coordinates.Position;
+
+ var radius = blobCoreComponent.FactoryRadiusLimit;
+
+ var innerTiles = grid.GetLocalTilesIntersecting(
+ new Box2(localPos + new Vector2(-radius, -radius), localPos + new Vector2(radius, radius)), false).ToArray();
+
+ foreach (var tileRef in innerTiles)
+ {
+ foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (!HasComp(ent))
+ continue;
+ _popup.PopupEntity(Loc.GetString("Слишком близко к другой фабрике"), uid, uid, PopupType.Large);
+ return;
+ }
+ }
+
+ if (!_blobCoreSystem.CheckNearNode(uid, xform.Coordinates, grid, blobCoreComponent))
+ return;
+
+ if (!_blobCoreSystem.TryUseAbility(uid, observerComponent.Core.Value, blobCoreComponent,
+ blobCoreComponent.FactoryBlobCost))
+ {
+ args.Handled = true;
+ return;
+ }
+
+ if (!_blobCoreSystem.TransformBlobTile(null,
+ observerComponent.Core.Value,
+ blobCoreComponent.FactoryBlobTile,
+ args.Target,
+ blobCoreComponent,
+ transformCost: blobCoreComponent.FactoryBlobCost))
+ return;
+
+ args.Handled = true;
+ }
+}
diff --git a/Content.Server/Blob/BlobResourceComponent.cs b/Content.Server/Blob/BlobResourceComponent.cs
new file mode 100644
index 00000000000..995e62cee23
--- /dev/null
+++ b/Content.Server/Blob/BlobResourceComponent.cs
@@ -0,0 +1,10 @@
+using Content.Shared.FixedPoint;
+
+namespace Content.Server.Blob;
+
+[RegisterComponent]
+public sealed class BlobResourceComponent : Component
+{
+ [ViewVariables(VVAccess.ReadWrite), DataField("pointsPerPulsed")]
+ public FixedPoint2 PointsPerPulsed = 3;
+}
diff --git a/Content.Server/Blob/BlobResourceSystem.cs b/Content.Server/Blob/BlobResourceSystem.cs
new file mode 100644
index 00000000000..b3f3f1a394c
--- /dev/null
+++ b/Content.Server/Blob/BlobResourceSystem.cs
@@ -0,0 +1,40 @@
+using Content.Shared.Blob;
+using Content.Shared.FixedPoint;
+using Content.Shared.Popups;
+
+namespace Content.Server.Blob;
+
+public sealed class BlobResourceSystem : EntitySystem
+{
+ [Dependency] private readonly BlobCoreSystem _blobCoreSystem = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnPulsed);
+ }
+
+ private void OnPulsed(EntityUid uid, BlobResourceComponent component, BlobTileGetPulseEvent args)
+ {
+ if (!TryComp(uid, out var blobTileComponent) || blobTileComponent.Core == null)
+ return;
+ if (!TryComp(blobTileComponent.Core, out var blobCoreComponent) ||
+ blobCoreComponent.Observer == null)
+ return;
+ _popup.PopupEntity(Loc.GetString("blob-get-resource", ("point", component.PointsPerPulsed)),
+ uid,
+ blobCoreComponent.Observer.Value,
+ PopupType.LargeGreen);
+
+ var points = component.PointsPerPulsed;
+
+ if (blobCoreComponent.CurrentChem == BlobChemType.RegenerativeMateria)
+ {
+ points += FixedPoint2.New(1);
+ }
+
+ _blobCoreSystem.ChangeBlobPoint(blobTileComponent.Core.Value, points);
+ }
+}
diff --git a/Content.Server/Blob/BlobSpawnerComponent.cs b/Content.Server/Blob/BlobSpawnerComponent.cs
new file mode 100644
index 00000000000..6faa9d4635a
--- /dev/null
+++ b/Content.Server/Blob/BlobSpawnerComponent.cs
@@ -0,0 +1,13 @@
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.Blob
+{
+ [RegisterComponent]
+ public sealed class BlobSpawnerComponent : Component
+ {
+ [ViewVariables(VVAccess.ReadWrite),
+ DataField("corePrototype", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string CoreBlobPrototype = "CoreBlobTile";
+ }
+}
diff --git a/Content.Server/Blob/BlobSpawnerSystem.cs b/Content.Server/Blob/BlobSpawnerSystem.cs
new file mode 100644
index 00000000000..ac987999f47
--- /dev/null
+++ b/Content.Server/Blob/BlobSpawnerSystem.cs
@@ -0,0 +1,34 @@
+using Content.Shared.Blob;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Player;
+
+namespace Content.Server.Blob
+{
+ public sealed class BlobSpawnerSystem : EntitySystem
+ {
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly BlobCoreSystem _blobCoreSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnPlayerAttached);
+ }
+
+ private void OnPlayerAttached(EntityUid uid, BlobSpawnerComponent component, PlayerAttachedEvent args)
+ {
+ var xform = Transform(uid);
+ if (!_mapManager.TryGetGrid(xform.GridUid, out var map))
+ return;
+
+ var core = Spawn(component.CoreBlobPrototype, xform.Coordinates);
+
+ if (!TryComp(core, out var blobCoreComponent))
+ return;
+
+ if (_blobCoreSystem.CreateBlobObserver(core, args.Player.UserId, blobCoreComponent))
+ QueueDel(uid);
+ }
+ }
+}
diff --git a/Content.Server/Blob/BlobTileComponent.cs b/Content.Server/Blob/BlobTileComponent.cs
new file mode 100644
index 00000000000..65e85dc6cef
--- /dev/null
+++ b/Content.Server/Blob/BlobTileComponent.cs
@@ -0,0 +1,55 @@
+using Content.Shared.Blob;
+using Content.Shared.Damage;
+using Content.Shared.FixedPoint;
+
+namespace Content.Server.Blob;
+
+[RegisterComponent]
+public sealed class BlobTileComponent : SharedBlobTileComponent
+{
+ [ViewVariables(VVAccess.ReadOnly)]
+ public EntityUid? Core = default!;
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public bool ReturnCost = true;
+
+ [ViewVariables(VVAccess.ReadOnly), DataField("tileType")]
+ public BlobTileType BlobTileType = BlobTileType.Normal;
+
+ [ViewVariables(VVAccess.ReadOnly), DataField("healthOfPulse")]
+ public DamageSpecifier HealthOfPulse = new()
+ {
+ DamageDict = new Dictionary
+ {
+ { "Blunt", -4 },
+ { "Slash", -4 },
+ { "Piercing", -4 },
+ { "Heat", -4 },
+ { "Cold", -4 },
+ { "Shock", -4 },
+ }
+ };
+
+ [ViewVariables(VVAccess.ReadOnly), DataField("flashDamage")]
+ public DamageSpecifier FlashDamage = new()
+ {
+ DamageDict = new Dictionary
+ {
+ { "Heat", 100 },
+ }
+ };
+}
+
+[Serializable]
+public enum BlobTileType : byte
+{
+ Normal,
+ Strong,
+ Reflective,
+ Resource,
+ Storage,
+ Node,
+ Factory,
+ Core,
+ None,
+}
diff --git a/Content.Server/Blob/BlobTileSystem.cs b/Content.Server/Blob/BlobTileSystem.cs
new file mode 100644
index 00000000000..13f0332ee11
--- /dev/null
+++ b/Content.Server/Blob/BlobTileSystem.cs
@@ -0,0 +1,529 @@
+using System.Linq;
+using System.Numerics;
+using Content.Server.Construction.Components;
+using Content.Server.Destructible;
+using Content.Server.Emp;
+using Content.Server.Flash;
+using Content.Server.Flash.Components;
+using Content.Shared.Blob;
+using Content.Shared.Damage;
+using Content.Shared.Destructible;
+using Content.Shared.FixedPoint;
+using Content.Shared.Popups;
+using Content.Shared.Verbs;
+using Robust.Server.GameObjects;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+
+namespace Content.Server.Blob;
+
+public sealed class BlobTileSystem : SharedBlobTileSystem
+{
+ [Dependency] private readonly IMapManager _map = default!;
+ [Dependency] private readonly DamageableSystem _damageableSystem = default!;
+ [Dependency] private readonly BlobCoreSystem _blobCoreSystem = default!;
+ [Dependency] private readonly SharedAudioSystem _audioSystem = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly EmpSystem _empSystem = default!;
+
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ // SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnDestruction);
+ SubscribeLocalEvent(OnPulsed);
+ SubscribeLocalEvent>(AddUpgradeVerb);
+ SubscribeLocalEvent>(AddRemoveVerb);
+ SubscribeLocalEvent(OnGetState);
+ SubscribeLocalEvent(OnFlashAttempt);
+ }
+
+ private void OnFlashAttempt(EntityUid uid, BlobTileComponent component, FlashAttemptEvent args)
+ {
+ if (args.Used == null || MetaData(args.Used.Value).EntityPrototype?.ID != "GrenadeFlashBang")
+ return;
+ if (component.BlobTileType == BlobTileType.Normal)
+ {
+ _damageableSystem.TryChangeDamage(uid, component.FlashDamage);
+ }
+ }
+
+ private void OnDestruction(EntityUid uid, BlobTileComponent component, DestructionEventArgs args)
+ {
+ if (component.Core == null || !TryComp(component.Core.Value, out var blobCoreComponent))
+ return;
+
+ var xform = Transform(uid);
+
+ if (blobCoreComponent.CurrentChem == BlobChemType.ElectromagneticWeb)
+ {
+ _empSystem.EmpPulse(xform.MapPosition, 3f, 50f, 3f);
+ }
+ }
+
+ private void AddRemoveVerb(EntityUid uid, BlobTileComponent component, GetVerbsEvent args)
+ {
+ if (!TryComp(args.User, out var ghostBlobComponent))
+ return;
+
+ if (ghostBlobComponent.Core == null ||
+ !TryComp(ghostBlobComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ if (ghostBlobComponent.Core.Value != component.Core)
+ return;
+
+ if (TryComp(uid, out var transformComponent) && !transformComponent.Anchored)
+ return;
+
+ if (HasComp(uid))
+ return;
+
+ Verb verb = new()
+ {
+ Act = () => TryRemove(uid, ghostBlobComponent.Core.Value, component, blobCoreComponent),
+ Text = Loc.GetString("blob-verb-remove-blob-tile"),
+ };
+ args.Verbs.Add(verb);
+ }
+
+ private void TryRemove(EntityUid target, EntityUid coreUid, BlobTileComponent tile, BlobCoreComponent core)
+ {
+ var xform = Transform(target);
+ if (!_blobCoreSystem.RemoveBlobTile(target, coreUid, core))
+ {
+ return;
+ }
+
+ FixedPoint2 returnCost = 0;
+
+ if (tile.ReturnCost)
+ {
+ switch (tile.BlobTileType)
+ {
+ case BlobTileType.Normal:
+ {
+ returnCost = core.NormalBlobCost * core.ReturnResourceOnRemove;
+ break;
+ }
+ case BlobTileType.Strong:
+ {
+ returnCost = core.StrongBlobCost * core.ReturnResourceOnRemove;
+ break;
+ }
+ case BlobTileType.Factory:
+ {
+ returnCost = core.FactoryBlobCost * core.ReturnResourceOnRemove;
+ break;
+ }
+ case BlobTileType.Resource:
+ {
+ returnCost = core.ResourceBlobCost * core.ReturnResourceOnRemove;
+ break;
+ }
+ case BlobTileType.Reflective:
+ {
+ returnCost = core.ReflectiveBlobCost * core.ReturnResourceOnRemove;
+ break;
+ }
+ case BlobTileType.Node:
+ {
+ returnCost = core.NodeBlobCost * core.ReturnResourceOnRemove;
+ break;
+ }
+ }
+ }
+
+ if (returnCost > 0)
+ {
+ if (TryComp(tile.Core, out var blobCoreComponent) && blobCoreComponent.Observer != null)
+ {
+ _popup.PopupCoordinates(Loc.GetString("blob-get-resource", ("point", returnCost)),
+ xform.Coordinates,
+ blobCoreComponent.Observer.Value,
+ PopupType.LargeGreen);
+ }
+ _blobCoreSystem.ChangeBlobPoint(coreUid, returnCost, core);
+ }
+ }
+
+ private void OnGetState(EntityUid uid, BlobTileComponent component, ref ComponentGetState args)
+ {
+ args.State = new BlobTileComponentState()
+ {
+ Color = component.Color
+ };
+ }
+
+ private void OnPulsed(EntityUid uid, BlobTileComponent component, BlobTileGetPulseEvent args)
+ {
+
+ if (!TryComp(uid, out var blobTileComponent) || blobTileComponent.Core == null ||
+ !TryComp(blobTileComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ if (blobCoreComponent.CurrentChem == BlobChemType.RegenerativeMateria)
+ {
+ var healCore = new DamageSpecifier();
+ foreach (var keyValuePair in component.HealthOfPulse.DamageDict)
+ {
+ healCore.DamageDict.Add(keyValuePair.Key, keyValuePair.Value * 10);
+ }
+ _damageableSystem.TryChangeDamage(uid, healCore);
+ }
+ else
+ {
+ _damageableSystem.TryChangeDamage(uid, component.HealthOfPulse);
+ }
+
+ if (!args.Explain)
+ return;
+
+ var xform = Transform(uid);
+
+ if (!_map.TryGetGrid(xform.GridUid, out var grid))
+ {
+ return;
+ }
+
+ var mobTile = grid.GetTileRef(xform.Coordinates);
+
+ var mobAdjacentTiles = new[]
+ {
+ mobTile.GridIndices.Offset(Direction.East),
+ mobTile.GridIndices.Offset(Direction.West),
+ mobTile.GridIndices.Offset(Direction.North),
+ mobTile.GridIndices.Offset(Direction.South)
+ };
+
+ var localPos = xform.Coordinates.Position;
+
+ var radius = 1.0f;
+
+ var innerTiles = grid.GetLocalTilesIntersecting(
+ new Box2(localPos + new Vector2(-radius, -radius), localPos + new Vector2(radius, radius))).ToArray();
+
+ foreach (var innerTile in innerTiles)
+ {
+ if (!mobAdjacentTiles.Contains(innerTile.GridIndices))
+ {
+ continue;
+ }
+
+ foreach (var ent in grid.GetAnchoredEntities(innerTile.GridIndices))
+ {
+ if (!HasComp(ent) || !HasComp(ent))
+ continue;
+ _damageableSystem.TryChangeDamage(ent, blobCoreComponent.ChemDamageDict[blobCoreComponent.CurrentChem]);
+ _audioSystem.PlayPvs(blobCoreComponent.AttackSound, uid, AudioParams.Default);
+ args.Explain = true;
+ return;
+ }
+ var spawn = true;
+ foreach (var ent in grid.GetAnchoredEntities(innerTile.GridIndices))
+ {
+ if (!HasComp(ent))
+ continue;
+ spawn = false;
+ break;
+ }
+
+ if (!spawn)
+ continue;
+
+ var location = innerTile.GridIndices.ToEntityCoordinates(xform.GridUid.Value, _map);
+
+ if (_blobCoreSystem.TransformBlobTile(null,
+ blobTileComponent.Core.Value,
+ blobCoreComponent.NormalBlobTile,
+ location,
+ blobCoreComponent,
+ false))
+ return;
+ }
+ }
+
+ private void AddUpgradeVerb(EntityUid uid, BlobTileComponent component, GetVerbsEvent args)
+ {
+ if (!TryComp(args.User, out var ghostBlobComponent))
+ return;
+
+ if (ghostBlobComponent.Core == null ||
+ !TryComp(ghostBlobComponent.Core.Value, out var blobCoreComponent))
+ return;
+
+ if (TryComp(uid, out var transformComponent) && !transformComponent.Anchored)
+ return;
+
+ var verbName = component.BlobTileType switch
+ {
+ BlobTileType.Normal => Loc.GetString("blob-verb-upgrade-to-strong"),
+ BlobTileType.Strong => Loc.GetString("blob-verb-upgrade-to-reflective"),
+ _ => "Upgrade"
+ };
+
+ AlternativeVerb verb = new()
+ {
+ Act = () => TryUpgrade(uid, args.User, ghostBlobComponent.Core.Value, component, blobCoreComponent),
+ Text = verbName
+ };
+ args.Verbs.Add(verb);
+ }
+
+ private void TryUpgrade(EntityUid target, EntityUid user, EntityUid coreUid, BlobTileComponent tile, BlobCoreComponent core)
+ {
+ var xform = Transform(target);
+ if (tile.BlobTileType == BlobTileType.Normal)
+ {
+ if (!_blobCoreSystem.TryUseAbility(user, coreUid, core, core.StrongBlobCost))
+ return;
+
+ _blobCoreSystem.TransformBlobTile(target,
+ coreUid,
+ core.StrongBlobTile,
+ xform.Coordinates,
+ core,
+ transformCost: core.StrongBlobCost);
+ }
+ else if (tile.BlobTileType == BlobTileType.Strong)
+ {
+ if (!_blobCoreSystem.TryUseAbility(user, coreUid, core, core.ReflectiveBlobCost))
+ return;
+
+ _blobCoreSystem.TransformBlobTile(target,
+ coreUid,
+ core.ReflectiveBlobTile,
+ xform.Coordinates,
+ core,
+ transformCost: core.ReflectiveBlobCost);
+ }
+ }
+
+ /* This work very bad.
+ I replace invisible
+ wall to teleportation observer
+ if he moving away from blob tile */
+
+ // private void OnStartup(EntityUid uid, BlobCellComponent component, ComponentStartup args)
+ // {
+ // var xform = Transform(uid);
+ // var radius = 2.5f;
+ // var wallSpacing = 1.5f; // Расстояние между стенами и центральной областью
+ //
+ // if (!_map.TryGetGrid(xform.GridUid, out var grid))
+ // {
+ // return;
+ // }
+ //
+ // var localpos = xform.Coordinates.Position;
+ //
+ // // Получаем тайлы в области с радиусом 2.5
+ // var allTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(localpos + new Vector2(-radius, -radius), localpos + new Vector2(radius, radius))).ToArray();
+ //
+ // // Получаем тайлы в области с радиусом 1.5
+ // var innerTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(localpos + new Vector2(-wallSpacing, -wallSpacing), localpos + new Vector2(wallSpacing, wallSpacing))).ToArray();
+ //
+ // foreach (var tileref in innerTiles)
+ // {
+ // foreach (var ent in grid.GetAnchoredEntities(tileref.GridIndices))
+ // {
+ // if (HasComp(ent))
+ // QueueDel(ent);
+ // if (HasComp(ent))
+ // {
+ // var blockTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(Transform(ent).Coordinates.Position + new Vector2(-wallSpacing, -wallSpacing),
+ // Transform(ent).Coordinates.Position + new Vector2(wallSpacing, wallSpacing))).ToArray();
+ // allTiles = allTiles.Except(blockTiles).ToArray();
+ // }
+ // }
+ // }
+ //
+ // var outerTiles = allTiles.Except(innerTiles).ToArray();
+ //
+ // foreach (var tileRef in outerTiles)
+ // {
+ // foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ // {
+ // if (HasComp(ent))
+ // {
+ // var blockTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(Transform(ent).Coordinates.Position + new Vector2(-wallSpacing, -wallSpacing),
+ // Transform(ent).Coordinates.Position + new Vector2(wallSpacing, wallSpacing))).ToArray();
+ // outerTiles = outerTiles.Except(blockTiles).ToArray();
+ // }
+ // }
+ // }
+ //
+ // foreach (var tileRef in outerTiles)
+ // {
+ // var spawn = true;
+ // foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ // {
+ // if (HasComp(ent))
+ // {
+ // spawn = false;
+ // break;
+ // }
+ // }
+ // if (spawn)
+ // EntityManager.SpawnEntity("BlobBorder", tileRef.GridIndices.ToEntityCoordinates(xform.GridUid.Value, _map));
+ // }
+ // }
+
+ // private void OnDestruction(EntityUid uid, BlobTileComponent component, DestructionEventArgs args)
+ // {
+ // var xform = Transform(uid);
+ // var radius = 1.0f;
+ //
+ // if (!_map.TryGetGrid(xform.GridUid, out var grid))
+ // {
+ // return;
+ // }
+ //
+ // var localPos = xform.Coordinates.Position;
+ //
+ // var innerTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(localPos + new Vector2(-radius, -radius), localPos + new Vector2(radius, radius)), false).ToArray();
+ //
+ // var centerTile = grid.GetLocalTilesIntersecting(
+ // new Box2(localPos, localPos)).ToArray();
+ //
+ // innerTiles = innerTiles.Except(centerTile).ToArray();
+ //
+ // foreach (var tileref in innerTiles)
+ // {
+ // foreach (var ent in grid.GetAnchoredEntities(tileref.GridIndices))
+ // {
+ // if (!HasComp(ent))
+ // continue;
+ // var blockTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(Transform(ent).Coordinates.Position + new Vector2(-radius, -radius),
+ // Transform(ent).Coordinates.Position + new Vector2(radius, radius)), false).ToArray();
+ //
+ // var tilesToRemove = new List();
+ //
+ // foreach (var blockTile in blockTiles)
+ // {
+ // tilesToRemove.Add(blockTile);
+ // }
+ //
+ // innerTiles = innerTiles.Except(tilesToRemove).ToArray();
+ // }
+ // }
+ //
+ // foreach (var tileRef in innerTiles)
+ // {
+ // foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ // {
+ // if (HasComp(ent))
+ // {
+ // QueueDel(ent);
+ // }
+ // }
+ // }
+ //
+ // EntityManager.SpawnEntity(component.BlobBorder, xform.Coordinates);
+ // }
+ //
+ // private void OnStartup(EntityUid uid, BlobTileComponent component, ComponentStartup args)
+ // {
+ // var xform = Transform(uid);
+ // var wallSpacing = 1.0f;
+ //
+ // if (!_map.TryGetGrid(xform.GridUid, out var grid))
+ // {
+ // return;
+ // }
+ //
+ // var localPos = xform.Coordinates.Position;
+ //
+ // var innerTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(localPos + new Vector2(-wallSpacing, -wallSpacing), localPos + new Vector2(wallSpacing, wallSpacing)), false).ToArray();
+ //
+ // var centerTile = grid.GetLocalTilesIntersecting(
+ // new Box2(localPos, localPos)).ToArray();
+ //
+ // foreach (var tileRef in centerTile)
+ // {
+ // foreach (var ent in grid.GetAnchoredEntities(tileRef.GridIndices))
+ // {
+ // if (HasComp(ent))
+ // QueueDel(ent);
+ // }
+ // }
+ // innerTiles = innerTiles.Except(centerTile).ToArray();
+ //
+ // foreach (var tileref in innerTiles)
+ // {
+ // var spaceNear = false;
+ // var hasBlobTile = false;
+ // foreach (var ent in grid.GetAnchoredEntities(tileref.GridIndices))
+ // {
+ // if (!HasComp(ent))
+ // continue;
+ // var blockTiles = grid.GetLocalTilesIntersecting(
+ // new Box2(Transform(ent).Coordinates.Position + new Vector2(-wallSpacing, -wallSpacing),
+ // Transform(ent).Coordinates.Position + new Vector2(wallSpacing, wallSpacing)), false).ToArray();
+ //
+ // var tilesToRemove = new List();
+ //
+ // foreach (var blockTile in blockTiles)
+ // {
+ // if (blockTile.Tile.IsEmpty)
+ // {
+ // spaceNear = true;
+ // }
+ // else
+ // {
+ // tilesToRemove.Add(blockTile);
+ // }
+ // }
+ //
+ // innerTiles = innerTiles.Except(tilesToRemove).ToArray();
+ //
+ // hasBlobTile = true;
+ // }
+ //
+ // if (!hasBlobTile || spaceNear)
+ // continue;
+ // {
+ // foreach (var ent in grid.GetAnchoredEntities(tileref.GridIndices))
+ // {
+ // if (HasComp(ent))
+ // {
+ // QueueDel(ent);
+ // }
+ // }
+ // }
+ // }
+ //
+ // var spaceNearCenter = false;
+ //
+ // foreach (var tileRef in innerTiles)
+ // {
+ // var spawn = true;
+ // if (tileRef.Tile.IsEmpty)
+ // {
+ // spaceNearCenter = true;
+ // spawn = false;
+ // }
+ // if (grid.GetAnchoredEntities(tileRef.GridIndices).Any(ent => HasComp(ent)))
+ // {
+ // spawn = false;
+ // }
+ // if (spawn)
+ // EntityManager.SpawnEntity(component.BlobBorder, tileRef.GridIndices.ToEntityCoordinates(xform.GridUid.Value, _map));
+ // }
+ // if (spaceNearCenter)
+ // {
+ // EntityManager.SpawnEntity(component.BlobBorder, xform.Coordinates);
+ // }
+ // }
+}
diff --git a/Content.Server/Blob/BlobbernautComponent.cs b/Content.Server/Blob/BlobbernautComponent.cs
new file mode 100644
index 00000000000..a3beb10baf7
--- /dev/null
+++ b/Content.Server/Blob/BlobbernautComponent.cs
@@ -0,0 +1,30 @@
+using Content.Shared.Blob;
+using Content.Shared.Damage;
+using Content.Shared.FixedPoint;
+
+namespace Content.Server.Blob;
+
+[RegisterComponent]
+public sealed class BlobbernautComponent : SharedBlobbernautComponent
+{
+ [ViewVariables(VVAccess.ReadWrite), DataField("damageFrequency")]
+ public float DamageFrequency = 5;
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public TimeSpan NextDamage = TimeSpan.Zero;
+
+ [ViewVariables(VVAccess.ReadOnly), DataField("damage")]
+ public DamageSpecifier Damage = new()
+ {
+ DamageDict = new Dictionary
+ {
+ { "Piercing", 25 },
+ }
+ };
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public bool IsDead = false;
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public EntityUid? Factory = default!;
+}
diff --git a/Content.Server/Blob/BlobbernautSystem.cs b/Content.Server/Blob/BlobbernautSystem.cs
new file mode 100644
index 00000000000..3852c3bd677
--- /dev/null
+++ b/Content.Server/Blob/BlobbernautSystem.cs
@@ -0,0 +1,124 @@
+using System.Linq;
+using System.Numerics;
+using Content.Server.Emp;
+using Content.Server.Explosion.EntitySystems;
+using Content.Shared.Blob;
+using Content.Shared.Damage;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Mobs;
+using Content.Shared.Popups;
+using Content.Shared.Weapons.Melee.Events;
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Blob
+{
+ public sealed class BlobbernautSystem : EntitySystem
+ {
+ [Dependency] private readonly IMapManager _map = default!;
+ [Dependency] private readonly DamageableSystem _damageableSystem = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly ExplosionSystem _explosionSystem = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly EmpSystem _empSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMobStateChanged);
+ SubscribeLocalEvent(OnGetState);
+ SubscribeLocalEvent(OnMeleeHit);
+ }
+
+ private void OnMeleeHit(EntityUid uid, BlobbernautComponent component, MeleeHitEvent args)
+ {
+ if (args.HitEntities.Count >= 1)
+ return;
+ if (!TryComp(component.Factory, out var blobTileComponent))
+ return;
+ if (!TryComp(blobTileComponent.Core, out var blobCoreComponent))
+ return;
+ if (blobCoreComponent.CurrentChem == BlobChemType.ExplosiveLattice)
+ {
+ _explosionSystem.QueueExplosion(args.HitEntities.FirstOrDefault(), blobCoreComponent.BlobExplosive, 4, 1, 2, maxTileBreak: 0);
+ }
+ if (blobCoreComponent.CurrentChem == BlobChemType.ElectromagneticWeb)
+ {
+ var xform = Transform(args.HitEntities.FirstOrDefault());
+ if (_random.Prob(0.2f))
+ _empSystem.EmpPulse(xform.MapPosition, 3f, 50f, 3f);
+ }
+ }
+
+ private void OnGetState(EntityUid uid, BlobbernautComponent component, ref ComponentGetState args)
+ {
+ args.State = new BlobbernautComponentState()
+ {
+ Color = component.Color
+ };
+ }
+
+ private void OnMobStateChanged(EntityUid uid, BlobbernautComponent component, MobStateChangedEvent args)
+ {
+ component.IsDead = args.NewMobState switch
+ {
+ MobState.Dead => true,
+ MobState.Alive => false,
+ _ => component.IsDead
+ };
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var blobFactoryQuery = EntityQueryEnumerator();
+ while (blobFactoryQuery.MoveNext(out var ent, out var comp))
+ {
+ if (comp.IsDead)
+ return;
+
+ if (_gameTiming.CurTime < comp.NextDamage)
+ return;
+
+ if (comp.Factory == null)
+ {
+ _popup.PopupEntity(Loc.GetString("blobberaut-factory-destroy"), ent, ent, PopupType.LargeCaution);
+ _damageableSystem.TryChangeDamage(ent, comp.Damage);
+ comp.NextDamage = _gameTiming.CurTime + TimeSpan.FromSeconds(comp.DamageFrequency);
+ return;
+ }
+
+ var xform = Transform(ent);
+
+ if (!_map.TryGetGrid(xform.GridUid, out var grid))
+ {
+ return;
+ }
+
+ var radius = 1f;
+
+ var localPos = xform.Coordinates.Position;
+ var nearbyTile = grid.GetLocalTilesIntersecting(
+ new Box2(localPos + new Vector2(-radius, -radius), localPos + new Vector2(radius, radius))).ToArray();
+
+ foreach (var tileRef in nearbyTile)
+ {
+ foreach (var entOnTile in grid.GetAnchoredEntities(tileRef.GridIndices))
+ {
+ if (TryComp(entOnTile, out var blobTileComponent) && blobTileComponent.Core != null)
+ return;
+ }
+ }
+
+ _popup.PopupEntity(Loc.GetString("blobberaut-not-on-blob-tile"), ent, ent, PopupType.LargeCaution);
+ _damageableSystem.TryChangeDamage(ent, comp.Damage);
+ comp.NextDamage = _gameTiming.CurTime + TimeSpan.FromSeconds(comp.DamageFrequency);
+ }
+ }
+ }
+}
diff --git a/Content.Server/Blob/NPC/BlobPod/BlobPodComponent.cs b/Content.Server/Blob/NPC/BlobPod/BlobPodComponent.cs
new file mode 100644
index 00000000000..273507f9d03
--- /dev/null
+++ b/Content.Server/Blob/NPC/BlobPod/BlobPodComponent.cs
@@ -0,0 +1,31 @@
+using Robust.Shared.Audio;
+
+namespace Content.Server.Blob.NPC.BlobPod
+{
+ [RegisterComponent]
+ public partial class BlobPodComponent : Component
+ {
+ [ViewVariables(VVAccess.ReadOnly)]
+ public bool IsZombifying = false;
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public EntityUid? ZombifiedEntityUid = default!;
+
+ [ViewVariables(VVAccess.ReadWrite), DataField("zombifyDelay")]
+ public float ZombifyDelay = 5.00f;
+
+ [ViewVariables(VVAccess.ReadOnly)]
+ public EntityUid? Core = null;
+
+ [ViewVariables(VVAccess.ReadWrite), DataField("zombifySoundPath")]
+ public SoundSpecifier ZombifySoundPath = new SoundPathSpecifier("/Audio/Effects/Fluids/blood1.ogg");
+
+ [ViewVariables(VVAccess.ReadWrite), DataField("zombifyFinishSoundPath")]
+ public SoundSpecifier ZombifyFinishSoundPath = new SoundPathSpecifier("/Audio/Effects/gib1.ogg");
+
+ public IPlayingAudioStream? ZombifyStingStream;
+ public EntityUid? ZombifyTarget;
+ }
+}
+
+
diff --git a/Content.Server/Blob/NPC/BlobPod/BlobPodSystem.cs b/Content.Server/Blob/NPC/BlobPod/BlobPodSystem.cs
new file mode 100644
index 00000000000..27df16690c3
--- /dev/null
+++ b/Content.Server/Blob/NPC/BlobPod/BlobPodSystem.cs
@@ -0,0 +1,157 @@
+using Content.Server.DoAfter;
+using Content.Server.Explosion.EntitySystems;
+using Content.Server.NPC.HTN;
+using Content.Server.Popups;
+using Content.Shared.ActionBlocker;
+using Content.Shared.Blob;
+using Content.Shared.CombatMode;
+using Content.Shared.Destructible;
+using Content.Shared.DoAfter;
+using Content.Shared.Humanoid;
+using Content.Shared.Interaction.Components;
+using Content.Shared.Inventory;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Rejuvenate;
+using Content.Shared.Verbs;
+using Robust.Shared.Audio.Systems;
+using Robust.Server.GameObjects;
+using Robust.Shared.Player;
+
+namespace Content.Server.Blob.NPC.BlobPod
+{
+ public sealed class BlobPodSystem : EntitySystem
+ {
+ [Dependency] private readonly DoAfterSystem _doAfter = default!;
+ [Dependency] private readonly MobStateSystem _mobs = default!;
+ [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
+ [Dependency] private readonly PopupSystem _popups = default!;
+ [Dependency] private readonly SharedAudioSystem _audioSystem = default!;
+ [Dependency] private readonly InventorySystem _inventory = default!;
+ [Dependency] private readonly ExplosionSystem _explosionSystem = default!;
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent>(AddDrainVerb);
+ SubscribeLocalEvent(OnZombify);
+ SubscribeLocalEvent(OnDestruction);
+ }
+
+ private void OnDestruction(EntityUid uid, BlobPodComponent component, DestructionEventArgs args)
+ {
+ if (!TryComp(component.Core, out var blobCoreComponent))
+ return;
+ if (blobCoreComponent.CurrentChem == BlobChemType.ExplosiveLattice)
+ {
+ _explosionSystem.QueueExplosion(uid, blobCoreComponent.BlobExplosive, 4, 1, 2, maxTileBreak: 0);
+ }
+ }
+
+ private void AddDrainVerb(EntityUid uid, BlobPodComponent component, GetVerbsEvent args)
+ {
+ if (args.User == args.Target)
+ return;
+ if (!args.CanAccess)
+ return;
+ if (!HasComp(args.Target))
+ return;
+ if (_mobs.IsAlive(args.Target))
+ return;
+
+ InnateVerb verb = new()
+ {
+ Act = () =>
+ {
+ NpcStartZombify(uid, args.Target, component);
+ },
+ Text = Loc.GetString("blob-pod-verb-zombify"),
+ // Icon = new SpriteSpecifier.Texture(new ("/Textures/")),
+ Priority = 2
+ };
+ args.Verbs.Add(verb);
+ }
+
+ private void OnZombify(EntityUid uid, BlobPodComponent component, BlobPodZombifyDoAfterEvent args)
+ {
+ component.IsZombifying = false;
+ if (args.Handled || args.Args.Target == null)
+ {
+ component.ZombifyStingStream?.Stop();
+ return;
+ }
+
+ if (args.Cancelled)
+ {
+ return;
+ }
+
+ _inventory.TryGetSlotEntity(args.Args.Target.Value, "head", out var headItem);
+ if (HasComp(headItem))
+ return;
+
+ _inventory.TryUnequip(args.Args.Target.Value, "head", true, true);
+ var equipped = _inventory.TryEquip(args.Args.Target.Value, uid, "head", true, true);
+
+ if (!equipped)
+ return;
+
+ _popups.PopupEntity(Loc.GetString("blob-mob-zombify-second-end", ("pod", uid)), args.Args.Target.Value, args.Args.Target.Value, Shared.Popups.PopupType.LargeCaution);
+ _popups.PopupEntity(Loc.GetString("blob-mob-zombify-third-end", ("pod", uid), ("target", args.Args.Target.Value)), args.Args.Target.Value, Filter.PvsExcept(args.Args.Target.Value), true, Shared.Popups.PopupType.LargeCaution);
+
+ EntityManager.RemoveComponent(uid);
+
+ EntityManager.RemoveComponent(uid);
+
+ EntityManager.EnsureComponent(uid);
+
+ _audioSystem.PlayPvs(component.ZombifyFinishSoundPath, uid);
+
+ var rejEv = new RejuvenateEvent();
+ RaiseLocalEvent(args.Args.Target.Value, rejEv);
+
+ component.ZombifiedEntityUid = args.Args.Target.Value;
+
+ var zombieBlob = EnsureComp(args.Args.Target.Value);
+ zombieBlob.BlobPodUid = uid;
+ }
+
+
+ public bool NpcStartZombify(EntityUid uid, EntityUid target, BlobPodComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return false;
+ if (!HasComp(target))
+ return false;
+ if (_mobs.IsAlive(target))
+ return false;
+ if (!_actionBlocker.CanInteract(uid, target))
+ return false;
+
+ StartZombify(uid, target, component);
+ return true;
+ }
+
+ public void StartZombify(EntityUid uid, EntityUid target, BlobPodComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ component.ZombifyTarget = target;
+ _popups.PopupEntity(Loc.GetString("blob-mob-zombify-second-start", ("pod", uid)), target, target, Shared.Popups.PopupType.LargeCaution);
+ _popups.PopupEntity(Loc.GetString("blob-mob-zombify-third-start", ("pod", uid), ("target", target)), target, Filter.PvsExcept(target), true, Shared.Popups.PopupType.LargeCaution);
+
+ component.ZombifyStingStream = _audioSystem.PlayPvs(component.ZombifySoundPath, target);
+ component.IsZombifying = true;
+
+ var ev = new BlobPodZombifyDoAfterEvent();
+ var args = new DoAfterArgs(uid, component.ZombifyDelay, ev, uid, target: target)
+ {
+ BreakOnTargetMove = true,
+ BreakOnUserMove = false,
+ DistanceThreshold = 2f,
+ NeedHand = false
+ };
+
+ _doAfter.TryStartDoAfter(args);
+ }
+ }
+}
diff --git a/Content.Server/Blob/ZombieBlobComponent.cs b/Content.Server/Blob/ZombieBlobComponent.cs
new file mode 100644
index 00000000000..849cc0d54bb
--- /dev/null
+++ b/Content.Server/Blob/ZombieBlobComponent.cs
@@ -0,0 +1,19 @@
+using Robust.Shared.Audio;
+
+namespace Content.Server.Blob;
+
+[RegisterComponent]
+public sealed class ZombieBlobComponent : Component
+{
+ public List OldFactions = new();
+
+ public EntityUid BlobPodUid = default!;
+
+ public float? OldColdDamageThreshold = null;
+
+ [ViewVariables]
+ public Dictionary DisabledFixtureMasks { get; } = new();
+
+ [DataField("greetSoundNotification")]
+ public SoundSpecifier GreetSoundNotification = new SoundPathSpecifier("/Audio/Ambience/Antag/zombie_start.ogg");
+}
diff --git a/Content.Server/Blob/ZombieBlobSystem.cs b/Content.Server/Blob/ZombieBlobSystem.cs
new file mode 100644
index 00000000000..a6a3704b318
--- /dev/null
+++ b/Content.Server/Blob/ZombieBlobSystem.cs
@@ -0,0 +1,183 @@
+using Content.Server.Atmos.Components;
+using Robust.Shared.Audio.Systems;
+using Content.Server.Body.Components;
+using Content.Server.Chat.Managers;
+using Content.Server.Mind;
+using Content.Shared.Mind.Components;
+using Content.Server.NPC;
+using Content.Server.NPC.Components;
+using Content.Server.NPC.HTN;
+using Content.Server.NPC.Systems;
+using Content.Server.Speech.Components;
+using Content.Server.Temperature.Components;
+using Content.Shared.Mobs;
+using Content.Shared.Physics;
+using Content.Shared.Tag;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Collision.Shapes;
+using Robust.Shared.Physics.Systems;
+
+namespace Content.Server.Blob
+{
+ public sealed class ZombieBlobSystem : EntitySystem
+ {
+ [Dependency] private readonly NpcFactionSystem _faction = default!;
+ [Dependency] private readonly NPCSystem _npc = default!;
+ [Dependency] private readonly MindSystem _mind = default!;
+ [Dependency] private readonly TagSystem _tagSystem = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly IChatManager _chatMan = default!;
+ [Dependency] private readonly SharedPhysicsSystem _physics = default!;
+ [Dependency] private readonly FixtureSystem _fixtureSystem = default!;
+
+ private const int ClimbingCollisionGroup = (int) (CollisionGroup.BlobImpassable);
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMobStateChanged);
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnShutdown);
+ }
+
+ ///
+ /// Replaces the current fixtures with non-climbing collidable versions so that climb end can be detected
+ ///
+ /// Returns whether adding the new fixtures was successful
+ private void ReplaceFixtures(EntityUid uid, ZombieBlobComponent climbingComp, FixturesComponent fixturesComp)
+ {
+ foreach (var (name, fixture) in fixturesComp.Fixtures)
+ {
+ if (climbingComp.DisabledFixtureMasks.ContainsKey(name)
+ || fixture.Hard == false
+ || (fixture.CollisionMask & ClimbingCollisionGroup) == 0)
+ continue;
+
+ climbingComp.DisabledFixtureMasks.Add(fixture.ID, fixture.CollisionMask & ClimbingCollisionGroup);
+ _physics.SetCollisionMask(uid, fixture, fixture.CollisionMask & ~ClimbingCollisionGroup, fixturesComp);
+ }
+ }
+
+ private void OnStartup(EntityUid uid, ZombieBlobComponent component, ComponentStartup args)
+ {
+ EnsureComp(uid);
+
+ var oldFactions = new List();
+ var factionComp = EnsureComp(uid);
+ foreach (var factionId in new List(factionComp.Factions))
+ {
+ oldFactions.Add(factionId);
+ _faction.RemoveFaction(uid, factionId);
+ }
+ _faction.AddFaction(uid, "Blob");
+ component.OldFactions = oldFactions;
+
+ var accent = EnsureComp(uid);
+ accent.Accent = "genericAggressive";
+
+ _tagSystem.AddTag(uid, "BlobMob");
+
+ EnsureComp(uid);
+
+ //EnsureComp(uid);
+
+ if (TryComp(uid, out var temperatureComponent))
+ {
+ component.OldColdDamageThreshold = temperatureComponent.ColdDamageThreshold;
+ temperatureComponent.ColdDamageThreshold = 0;
+ }
+
+ if (TryComp(uid, out var fixturesComp))
+ {
+ ReplaceFixtures(uid, component, fixturesComp);
+ }
+
+ var mindComp = EnsureComp(uid);
+ if (_mind.TryGetMind(uid, out var mind, mindComp) && _mind.TryGetSession(mind, out var session))
+ {
+ _chatMan.DispatchServerMessage(session, Loc.GetString("blob-zombie-greeting"));
+
+ _audio.PlayGlobal(component.GreetSoundNotification, session);
+ }
+ else
+ {
+ var htn = EnsureComp(uid);
+ htn.RootTask = new HTNCompoundTask() {Task = "SimpleHostileCompound"};
+ htn.Blackboard.SetValue(NPCBlackboard.Owner, uid);
+ _npc.WakeNPC(uid, htn);
+ }
+ }
+
+ private void OnShutdown(EntityUid uid, ZombieBlobComponent component, ComponentShutdown args)
+ {
+ if (TerminatingOrDeleted(uid))
+ {
+ return;
+ }
+
+ if (HasComp(uid))
+ {
+ RemComp(uid);
+ }
+
+ if (HasComp(uid))
+ {
+ RemComp(uid);
+ }
+
+ if (HasComp(uid))
+ {
+ RemComp(uid);
+ }
+
+ if (HasComp(uid))
+ {
+ RemComp(uid);
+ }
+
+ // if (HasComp(uid))
+ // {
+ // RemComp(uid);
+ // }
+
+ if (TryComp(uid, out var temperatureComponent) && component.OldColdDamageThreshold != null)
+ {
+ temperatureComponent.ColdDamageThreshold = component.OldColdDamageThreshold.Value;
+ }
+
+ _tagSystem.RemoveTag(uid, "BlobMob");
+
+ QueueDel(component.BlobPodUid);
+
+ EnsureComp(uid);
+ foreach (var factionId in component.OldFactions)
+ {
+ _faction.AddFaction(uid, factionId);
+ }
+ _faction.RemoveFaction(uid, "Blob");
+
+ if (TryComp(uid, out var fixtures))
+ {
+ foreach (var (name, fixtureMask) in component.DisabledFixtureMasks)
+ {
+ if (!fixtures.Fixtures.TryGetValue(name, out var fixture))
+ {
+ continue;
+ }
+
+ _physics.SetCollisionMask(uid, fixture, fixture.CollisionMask | fixtureMask, fixtures);
+ }
+ component.DisabledFixtureMasks.Clear();
+ }
+ }
+
+ private void OnMobStateChanged(EntityUid uid, ZombieBlobComponent component, MobStateChangedEvent args)
+ {
+ if (args.NewMobState == MobState.Dead && !Deleted(uid))
+ {
+ RemComp(uid);
+ }
+ }
+ }
+}
diff --git a/Content.Server/Body/Components/RespiratorComponent.cs b/Content.Server/Body/Components/RespiratorComponent.cs
index 9f080a3dd9d..3b05b60b933 100644
--- a/Content.Server/Body/Components/RespiratorComponent.cs
+++ b/Content.Server/Body/Components/RespiratorComponent.cs
@@ -60,6 +60,12 @@ public sealed partial class RespiratorComponent : Component
public float CycleDelay = 2.0f;
public float AccumulatedFrametime;
+
+ ///
+ /// Whether the entity is immuned to pressure (i.e possess the PressureImmunity component)
+ ///
+ [ViewVariables]
+ public bool HasImmunity = false;
}
}
@@ -67,4 +73,4 @@ public enum RespiratorStatus
{
Inhaling,
Exhaling
-}
+}
\ No newline at end of file
diff --git a/Content.Server/Chemistry/Components/SmokeComponent.cs b/Content.Server/Chemistry/Components/SmokeComponent.cs
new file mode 100644
index 00000000000..d0873306724
--- /dev/null
+++ b/Content.Server/Chemistry/Components/SmokeComponent.cs
@@ -0,0 +1,31 @@
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Fluids.Components;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Chemistry.Components;
+
+///
+/// Stores solution on an anchored entity that has touch and ingestion reactions
+/// to entities that collide with it. Similar to
+///
+[RegisterComponent]
+public partial class SmokeComponent : SharedSmokeComponent
+{
+ public const string SolutionName = "solutionArea";
+
+ [DataField("nextReact", customTypeSerializer:typeof(TimeOffsetSerializer))]
+ public TimeSpan NextReact = TimeSpan.Zero;
+
+ [DataField("spreadAmount")]
+ public int SpreadAmount = 0;
+
+ [DataField("smokeColor")]
+ public Color SmokeColor = Color.Black;
+
+ ///
+ /// Have we reacted with our tile yet?
+ ///
+ [DataField("reactedTile")]
+ public bool ReactedTile = false;
+}
+
diff --git a/Content.Server/Fluids/EntitySystems/SmokeOnTriggerComponent.cs b/Content.Server/Fluids/EntitySystems/SmokeOnTriggerComponent.cs
new file mode 100644
index 00000000000..298dc8faa3d
--- /dev/null
+++ b/Content.Server/Fluids/EntitySystems/SmokeOnTriggerComponent.cs
@@ -0,0 +1,22 @@
+using Content.Shared.Chemistry.Components;
+using Robust.Shared.Audio;
+
+namespace Content.Server.Fluids.EntitySystems;
+
+[RegisterComponent]
+public sealed class SmokeOnTriggerComponent : Component
+{
+ [DataField("spreadAmount"), ViewVariables(VVAccess.ReadWrite)]
+ public int SpreadAmount = 20;
+
+ [DataField("time"), ViewVariables(VVAccess.ReadWrite)]
+ public float Time = 20f;
+
+ [DataField("smokeColor")]
+ public Color SmokeColor = Color.Black;
+
+ [DataField("smokeReagents")] public List SmokeReagents = new();
+
+ [DataField("sound")]
+ public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Effects/smoke.ogg");
+}
diff --git a/Content.Server/Fluids/EntitySystems/SmokeSystem.cs b/Content.Server/Fluids/EntitySystems/SmokeSystem.cs
index ae170842a0c..76b10b09262 100644
--- a/Content.Server/Fluids/EntitySystems/SmokeSystem.cs
+++ b/Content.Server/Fluids/EntitySystems/SmokeSystem.cs
@@ -3,6 +3,7 @@
using Content.Server.Body.Systems;
using Content.Server.Chemistry.Containers.EntitySystems;
using Content.Server.Chemistry.ReactionEffects;
+using Content.Server.Explosion.EntitySystems;
using Content.Server.Spreader;
using Content.Shared.Chemistry;
using Content.Shared.Chemistry.Components;
diff --git a/Content.Server/GameTicking/Rules/BlobRuleSystem.cs b/Content.Server/GameTicking/Rules/BlobRuleSystem.cs
new file mode 100644
index 00000000000..09f5e009734
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/BlobRuleSystem.cs
@@ -0,0 +1,183 @@
+using System.Linq;
+using Content.Server.Chat.Systems;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Mind;
+using Content.Server.Nuke;
+using Content.Server.RoundEnd;
+using Content.Server.Station.Systems;
+using Content.Shared.Blob;
+
+namespace Content.Server.GameTicking.Rules;
+
+public sealed class BlobRuleSystem : GameRuleSystem
+{
+ [Dependency] private readonly MindSystem _mindSystem = default!;
+ [Dependency] private readonly RoundEndSystem _roundEndSystem = default!;
+ [Dependency] private readonly ChatSystem _chatSystem = default!;
+ [Dependency] private readonly NukeCodePaperSystem _nukeCode = default!;
+ [Dependency] private readonly StationSystem _stationSystem = default!;
+
+ private ISawmill _sawmill = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _sawmill = Logger.GetSawmill("preset");
+
+ SubscribeLocalEvent(OnRoundEndText);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var blobFactoryQuery = EntityQueryEnumerator();
+ while (blobFactoryQuery.MoveNext(out var blobRuleUid, out var blobRuleComp))
+ {
+ var blobCoreQuery = EntityQueryEnumerator();
+ while (blobCoreQuery.MoveNext(out var ent, out var comp))
+ {
+ if (comp.BlobTiles.Count >= 50)
+ {
+ if (_roundEndSystem.ExpectedCountdownEnd != null)
+ {
+ _roundEndSystem.CancelRoundEndCountdown(checkCooldown: false);
+ _chatSystem.DispatchGlobalAnnouncement(Loc.GetString("blob-alert-recall-shuttle"),
+ Loc.GetString("Station"),
+ false,
+ null,
+ Color.Red);
+ }
+ }
+
+ switch (blobRuleComp.Stage)
+ {
+ case BlobStage.Default when comp.BlobTiles.Count < 50:
+ continue;
+ case BlobStage.Default:
+ _chatSystem.DispatchGlobalAnnouncement(Loc.GetString("blob-alert-detect"),
+ Loc.GetString("Station"),
+ true,
+ blobRuleComp.AlertAudio,
+ Color.Red);
+ blobRuleComp.Stage = BlobStage.Begin;
+ break;
+ case BlobStage.Begin:
+ {
+ if (comp.BlobTiles.Count >= 300)
+ {
+ _chatSystem.DispatchGlobalAnnouncement(Loc.GetString("blob-alert-critical"),
+ Loc.GetString("Station"),
+ true,
+ blobRuleComp.AlertAudio,
+ Color.Red);
+ var stationUid = _stationSystem.GetOwningStation(ent);
+ if (stationUid != null)
+ _nukeCode.SendNukeCodes(stationUid.Value);
+ blobRuleComp.Stage = BlobStage.Critical;
+ }
+ break;
+ }
+ case BlobStage.Critical:
+ {
+ if (comp.BlobTiles.Count >= 400)
+ {
+ comp.Points = 99999;
+ _roundEndSystem.EndRound();
+ }
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ private void OnRoundEndText(RoundEndTextAppendEvent ev)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var blob, out var gameRule))
+ {
+ if (!GameTicker.IsGameRuleAdded(uid, gameRule))
+ continue;
+
+ if (blob.Blobs.Count < 1)
+ return;
+
+ var result = Loc.GetString("blob-round-end-result", ("blobCount", blob.Blobs.Count));
+
+ // yeah this is duplicated from traitor rules lol, there needs to be a generic rewrite where it just goes through all minds with objectives
+ foreach (var t in blob.Blobs)
+ {
+ var name = t.Mind.CharacterName;
+ _mindSystem.TryGetSession(t.Mind, out var session);
+ var username = session?.Name;
+
+ var objectives = t.Mind.AllObjectives.ToArray();
+ if (objectives.Length == 0)
+ {
+ if (username != null)
+ {
+ if (name == null)
+ result += "\n" + Loc.GetString("blob-user-was-a-blob", ("user", username));
+ else
+ {
+ result += "\n" + Loc.GetString("blob-user-was-a-blob-named", ("user", username),
+ ("name", name));
+ }
+ }
+ else if (name != null)
+ result += "\n" + Loc.GetString("blob-was-a-blob-named", ("name", name));
+
+ continue;
+ }
+
+ if (username != null)
+ {
+ if (name == null)
+ {
+ result += "\n" + Loc.GetString("blob-user-was-a-blob-with-objectives",
+ ("user", username));
+ }
+ else
+ {
+ result += "\n" + Loc.GetString("blob-user-was-a-blob-with-objectives-named",
+ ("user", username), ("name", name));
+ }
+ }
+ else if (name != null)
+ result += "\n" + Loc.GetString("blob-was-a-blob-with-objectives-named", ("name", name));
+
+ foreach (var objectiveGroup in objectives.GroupBy(o => o.Prototype.Issuer))
+ {
+ foreach (var objective in objectiveGroup)
+ {
+ foreach (var condition in objective.Conditions)
+ {
+ var progress = condition.Progress;
+ if (progress > 0.99f)
+ {
+ result += "\n- " + Loc.GetString(
+ "traitor-objective-condition-success",
+ ("condition", condition.Title),
+ ("markupColor", "green")
+ );
+ }
+ else
+ {
+ result += "\n- " + Loc.GetString(
+ "traitor-objective-condition-fail",
+ ("condition", condition.Title),
+ ("progress", (int) (progress * 100)),
+ ("markupColor", "red")
+ );
+ }
+ }
+ }
+ }
+ }
+
+ ev.AddLine(result);
+ }
+ }
+}
diff --git a/Content.Server/GameTicking/Rules/Components/BlobRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/BlobRuleComponent.cs
new file mode 100644
index 00000000000..7da7a58cd0a
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/Components/BlobRuleComponent.cs
@@ -0,0 +1,26 @@
+using Content.Server.Blob;
+using Content.Server.Roles;
+using Robust.Shared.Audio;
+
+namespace Content.Server.GameTicking.Rules.Components;
+
+[RegisterComponent, Access(typeof(BlobRuleSystem), typeof(BlobCoreSystem))]
+public sealed class BlobRuleComponent : Component
+{
+ public List Blobs = new();
+
+ public BlobStage Stage = BlobStage.Default;
+
+ [DataField("alertAodio")]
+ public SoundSpecifier? AlertAudio = new SoundPathSpecifier("/Audio/Announcements/attention.ogg");
+}
+
+
+public enum BlobStage : byte
+{
+ Default,
+ Begin,
+ Medium,
+ Critical,
+ TheEnd
+}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/BlobPodZombifyOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/BlobPodZombifyOperator.cs
new file mode 100644
index 00000000000..11126b2de26
--- /dev/null
+++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/BlobPodZombifyOperator.cs
@@ -0,0 +1,47 @@
+using Content.Server.Blob.NPC.BlobPod;
+
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Specific;
+
+public sealed class BlobPodZombifyOperator : HTNOperator
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ private BlobPodSystem _blobPodSystem = default!;
+
+ [DataField("zombifyKey")]
+ public string ZombifyKey = string.Empty;
+
+ public override void Initialize(IEntitySystemManager sysManager)
+ {
+ base.Initialize(sysManager);
+ _blobPodSystem = sysManager.GetEntitySystem();
+ }
+
+ public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
+ {
+ var owner = blackboard.GetValue(NPCBlackboard.Owner);
+ var target = blackboard.GetValue(ZombifyKey);
+
+ if (!target.IsValid() || _entManager.Deleted(target))
+ return HTNOperatorStatus.Failed;
+
+ if (!_entManager.TryGetComponent(owner, out var pod))
+ return HTNOperatorStatus.Failed;
+
+ if (pod.ZombifiedEntityUid != null)
+ return HTNOperatorStatus.Continuing;
+
+ if (pod.IsZombifying)
+ return HTNOperatorStatus.Continuing;
+
+ if (pod.ZombifyTarget == null)
+ {
+ if (_blobPodSystem.NpcStartZombify(owner, target, pod))
+ return HTNOperatorStatus.Continuing;
+ else
+ return HTNOperatorStatus.Failed;
+ }
+
+ pod.ZombifyTarget = null;
+ return HTNOperatorStatus.Finished;
+ }
+}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/PickBlobPodZombifyTargetOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/PickBlobPodZombifyTargetOperator.cs
new file mode 100644
index 00000000000..a56e7a0ebd3
--- /dev/null
+++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/PickBlobPodZombifyTargetOperator.cs
@@ -0,0 +1,89 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Content.Server.NPC.Pathfinding;
+using Content.Server.NPC.Systems;
+using Content.Shared.Humanoid;
+using Content.Shared.Mobs.Systems;
+
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Specific;
+
+public sealed class PickBlobPodZombifyTargetOperator : HTNOperator
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ private NpcFactionSystem _factions = default!;
+ private MobStateSystem _mobSystem = default!;
+
+ private EntityLookupSystem _lookup = default!;
+ private PathfindingSystem _pathfinding = default!;
+
+ [DataField("rangeKey", required: true)]
+ public string RangeKey = string.Empty;
+
+ [DataField("targetKey", required: true)]
+ public string TargetKey = string.Empty;
+
+ [DataField("zombifyKey")]
+ public string ZombifyKey = string.Empty;
+
+ ///
+ /// Where the pathfinding result will be stored (if applicable). This gets removed after execution.
+ ///
+ [DataField("pathfindKey")]
+ public string PathfindKey = NPCBlackboard.PathfindKey;
+
+ public override void Initialize(IEntitySystemManager sysManager)
+ {
+ base.Initialize(sysManager);
+ _lookup = sysManager.GetEntitySystem();
+ _pathfinding = sysManager.GetEntitySystem();
+ _mobSystem = sysManager.GetEntitySystem();
+ _factions = sysManager.GetEntitySystem();
+ }
+
+ public override async Task<(bool Valid, Dictionary? Effects)> Plan(NPCBlackboard blackboard,
+ CancellationToken cancelToken)
+ {
+ var owner = blackboard.GetValue(NPCBlackboard.Owner);
+
+ if (!blackboard.TryGetValue(RangeKey, out var range, _entManager))
+ return (false, null);
+
+ var huAppQuery = _entManager.GetEntityQuery();
+ var xformQuery = _entManager.GetEntityQuery();
+
+ var targets = new List();
+
+ foreach (var entity in _factions.GetNearbyHostiles(owner, range))
+ {
+ if (!huAppQuery.TryGetComponent(entity, out var humanoidAppearance))
+ continue;
+
+ if (_mobSystem.IsAlive(entity))
+ continue;
+
+ targets.Add(entity);
+ }
+
+ foreach (var target in targets)
+ {
+ if (!xformQuery.TryGetComponent(target, out var xform))
+ continue;
+
+ var targetCoords = xform.Coordinates;
+ var path = await _pathfinding.GetPath(owner, target, range, cancelToken);
+ if (path.Result != PathResult.Path)
+ {
+ continue;
+ }
+
+ return (true, new Dictionary()
+ {
+ { TargetKey, targetCoords },
+ { ZombifyKey, target },
+ { PathfindKey, path}
+ });
+ }
+
+ return (false, null);
+ }
+}
diff --git a/Content.Server/Objectives/Conditions/BlobCaptureCondition.cs b/Content.Server/Objectives/Conditions/BlobCaptureCondition.cs
new file mode 100644
index 00000000000..bd5b6a22451
--- /dev/null
+++ b/Content.Server/Objectives/Conditions/BlobCaptureCondition.cs
@@ -0,0 +1,71 @@
+using Content.Server.Blob;
+using Content.Server.Objectives.Interfaces;
+using Content.Shared.Blob;
+using JetBrains.Annotations;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Objectives.Conditions;
+
+[UsedImplicitly]
+[DataDefinition]
+public partial class BlobCaptureCondition : IObjectiveCondition
+{
+ private Mind.Mind? _mind;
+ private int _target;
+
+ public IObjectiveCondition GetAssigned(Mind.Mind mind)
+ {
+ return new BlobCaptureCondition
+ {
+ _mind = mind,
+ _target = 400
+ };
+ }
+
+ public string Title => Loc.GetString("objective-condition-blob-capture-title");
+
+ public string Description => Loc.GetString("objective-condition-blob-capture-description", ("count", _target));
+
+ public SpriteSpecifier Icon => new SpriteSpecifier.Rsi(new ResPath("Mobs/Aliens/blob.rsi"), "blob_nuke_overlay");
+
+ public float Progress
+ {
+ get
+ {
+ var entMan = IoCManager.Resolve