diff --git a/Content.Client/Anomaly/AnomalySystem.cs b/Content.Client/Anomaly/AnomalySystem.cs
new file mode 100644
index 0000000000..3f42a3e5f7
--- /dev/null
+++ b/Content.Client/Anomaly/AnomalySystem.cs
@@ -0,0 +1,59 @@
+using Content.Shared.Anomaly;
+using Content.Shared.Anomaly.Components;
+using Robust.Client.GameObjects;
+using Robust.Shared.Timing;
+
+namespace Content.Client.Anomaly;
+
+public sealed class AnomalySystem : SharedAnomalySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnAppearanceChanged);
+ }
+
+ private void OnAppearanceChanged(EntityUid uid, AnomalyComponent component, ref AppearanceChangeEvent args)
+ {
+ if (args.Sprite is not { } sprite)
+ return;
+
+ if (!Appearance.TryGetData(uid, AnomalyVisuals.IsPulsing, out bool pulsing, args.Component))
+ pulsing = false;
+
+ if (Appearance.TryGetData(uid, AnomalyVisuals.IsPulsing, out bool super, args.Component) && super)
+ pulsing = super;
+
+ if (HasComp(uid))
+ pulsing = true;
+
+ if (!sprite.LayerMapTryGet(AnomalyVisualLayers.Base, out var layer) ||
+ !sprite.LayerMapTryGet(AnomalyVisualLayers.Animated, out var animatedLayer))
+ return;
+
+ sprite.LayerSetVisible(layer, !pulsing);
+ sprite.LayerSetVisible(animatedLayer, pulsing);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ foreach (var (super, sprite) in EntityQuery())
+ {
+ var completion = 1f - (float) ((super.EndTime - _timing.CurTime) / super.SupercriticalDuration);
+ var scale = completion * (super.MaxScaleAmount - 1f) + 1f;
+ sprite.Scale = new Vector2(scale, scale);
+
+ var transparency = (byte) (65 * (1f - completion) + 190);
+ if (transparency < sprite.Color.AByte)
+ {
+ sprite.Color = sprite.Color.WithAlpha(transparency);
+ }
+ }
+ }
+}
diff --git a/Content.Client/Anomaly/Effects/GravityAnomalySystem.cs b/Content.Client/Anomaly/Effects/GravityAnomalySystem.cs
new file mode 100644
index 0000000000..444e57b3f4
--- /dev/null
+++ b/Content.Client/Anomaly/Effects/GravityAnomalySystem.cs
@@ -0,0 +1,8 @@
+using Content.Shared.Anomaly.Effects;
+
+namespace Content.Client.Anomaly.Effects;
+
+public sealed class GravityAnomalySystem : SharedGravityAnomalySystem
+{
+ // this is not the system you are looking for
+}
diff --git a/Content.Client/Anomaly/Ui/AnomalyGeneratorBoundUserInterface.cs b/Content.Client/Anomaly/Ui/AnomalyGeneratorBoundUserInterface.cs
new file mode 100644
index 0000000000..9d8923a2e1
--- /dev/null
+++ b/Content.Client/Anomaly/Ui/AnomalyGeneratorBoundUserInterface.cs
@@ -0,0 +1,54 @@
+using Content.Shared.Anomaly;
+using Content.Shared.Gravity;
+using JetBrains.Annotations;
+using Robust.Client.GameObjects;
+
+namespace Content.Client. Anomaly.Ui;
+
+[UsedImplicitly]
+public sealed class AnomalyGeneratorBoundUserInterface : BoundUserInterface
+{
+ private AnomalyGeneratorWindow? _window;
+
+ public AnomalyGeneratorBoundUserInterface(ClientUserInterfaceComponent owner, Enum uiKey) : base (owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new (Owner.Owner);
+
+ _window.OpenCentered();
+ _window.OnClose += Close;
+
+ _window.OnGenerateButtonPressed += () =>
+ {
+ SendMessage(new AnomalyGeneratorGenerateButtonPressedEvent());
+ };
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is not AnomalyGeneratorUserInterfaceState msg)
+ return;
+ _window?.UpdateState(msg);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing) return;
+
+ _window?.Dispose();
+ }
+
+ public void SetPowerSwitch(bool on)
+ {
+ SendMessage(new SharedGravityGeneratorComponent.SwitchGeneratorMessage(on));
+ }
+}
+
diff --git a/Content.Client/Anomaly/Ui/AnomalyGeneratorWindow.xaml b/Content.Client/Anomaly/Ui/AnomalyGeneratorWindow.xaml
new file mode 100644
index 0000000000..c818d263f6
--- /dev/null
+++ b/Content.Client/Anomaly/Ui/AnomalyGeneratorWindow.xaml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Anomaly/Ui/AnomalyGeneratorWindow.xaml.cs b/Content.Client/Anomaly/Ui/AnomalyGeneratorWindow.xaml.cs
new file mode 100644
index 0000000000..cf46f0f8fd
--- /dev/null
+++ b/Content.Client/Anomaly/Ui/AnomalyGeneratorWindow.xaml.cs
@@ -0,0 +1,80 @@
+using Content.Client.Message;
+using Content.Shared.Anomaly;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+using FancyWindow = Content.Client.UserInterface.Controls.FancyWindow;
+
+namespace Content.Client.Anomaly.Ui;
+
+[GenerateTypedNameReferences]
+public sealed partial class AnomalyGeneratorWindow : FancyWindow
+{
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private TimeSpan _cooldownEnd = TimeSpan.Zero;
+ private bool _hasEnoughFuel;
+
+ public Action? OnGenerateButtonPressed;
+
+ public AnomalyGeneratorWindow(EntityUid gen)
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ EntityView.Sprite = _entityManager.GetComponent(gen);
+
+ GenerateButton.OnPressed += _ => OnGenerateButtonPressed?.Invoke();
+ }
+
+ public void UpdateState(AnomalyGeneratorUserInterfaceState state)
+ {
+ _cooldownEnd = state.CooldownEndTime;
+ _hasEnoughFuel = state.FuelCost <= state.FuelAmount;
+
+ var fuelCompletion = Math.Clamp((float) state.FuelAmount / state.FuelCost, 0f, 1f);
+
+ FuelBar.Value = fuelCompletion;
+ FuelText.Text = $"{fuelCompletion:P}";
+
+ UpdateTimer();
+ UpdateReady(); // yes this can trigger twice. no i don't care
+ }
+
+ public void UpdateTimer()
+ {
+ if (_timing.CurTime > _cooldownEnd)
+ {
+ CooldownLabel.SetMarkup(Loc.GetString("anomaly-generator-no-cooldown"));
+ }
+ else
+ {
+ var timeLeft = _cooldownEnd - _timing.CurTime;
+ var timeString = $"{timeLeft.Minutes:0}:{timeLeft.Seconds:00}";
+ CooldownLabel.SetMarkup(Loc.GetString("anomaly-generator-cooldown", ("time", timeString)));
+ UpdateReady();
+ }
+ }
+
+ public void UpdateReady()
+ {
+ var ready = _hasEnoughFuel && _timing.CurTime > _cooldownEnd;
+
+ var msg = ready
+ ? Loc.GetString("anomaly-generator-yes-fire")
+ : Loc.GetString("anomaly-generator-no-fire");
+ ReadyLabel.SetMarkup(msg);
+
+ GenerateButton.Disabled = !ready;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ UpdateTimer();
+ }
+}
+
diff --git a/Content.Client/Anomaly/Ui/AnomalyScannerBoundUserInterface.cs b/Content.Client/Anomaly/Ui/AnomalyScannerBoundUserInterface.cs
new file mode 100644
index 0000000000..64f66818eb
--- /dev/null
+++ b/Content.Client/Anomaly/Ui/AnomalyScannerBoundUserInterface.cs
@@ -0,0 +1,48 @@
+using Content.Shared.Anomaly;
+using JetBrains.Annotations;
+using Robust.Client.GameObjects;
+
+namespace Content.Client.Anomaly.Ui;
+
+[UsedImplicitly]
+public sealed class AnomalyScannerBoundUserInterface : BoundUserInterface
+{
+ private AnomalyScannerMenu? _menu;
+
+ public AnomalyScannerBoundUserInterface(ClientUserInterfaceComponent owner, Enum uiKey) : base(owner, uiKey)
+ {
+
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _menu = new AnomalyScannerMenu();
+ _menu.OpenCentered();
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is not AnomalyScannerUserInterfaceState msg)
+ return;
+
+ if (_menu == null)
+ return;
+
+ _menu.LastMessage = msg.Message;
+ _menu.NextPulseTime = msg.NextPulseTime;
+ _menu.UpdateMenu();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+ _menu?.Dispose();
+ }
+}
+
diff --git a/Content.Client/Anomaly/Ui/AnomalyScannerMenu.xaml b/Content.Client/Anomaly/Ui/AnomalyScannerMenu.xaml
new file mode 100644
index 0000000000..ac4adf7e0e
--- /dev/null
+++ b/Content.Client/Anomaly/Ui/AnomalyScannerMenu.xaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/Content.Client/Anomaly/Ui/AnomalyScannerMenu.xaml.cs b/Content.Client/Anomaly/Ui/AnomalyScannerMenu.xaml.cs
new file mode 100644
index 0000000000..14726952d1
--- /dev/null
+++ b/Content.Client/Anomaly/Ui/AnomalyScannerMenu.xaml.cs
@@ -0,0 +1,47 @@
+using Content.Client.Message;
+using Content.Client.UserInterface.Controls;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Anomaly.Ui;
+
+[GenerateTypedNameReferences]
+public sealed partial class AnomalyScannerMenu : FancyWindow
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ public FormattedMessage LastMessage = new();
+ public TimeSpan? NextPulseTime;
+
+ public AnomalyScannerMenu()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ }
+
+ public void UpdateMenu()
+ {
+ var msg = new FormattedMessage(LastMessage);
+
+ if (NextPulseTime != null)
+ {
+ msg.PushNewline();
+ msg.PushNewline();
+ var time = NextPulseTime.Value - _timing.CurTime;
+ var timestring = $"{time.Minutes:00}:{time.Seconds:00}";
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-pulse-timer", ("time", timestring)));
+ }
+
+ TextDisplay.SetMarkup(msg.ToMarkup());
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ if (NextPulseTime != null)
+ UpdateMenu();
+ }
+}
diff --git a/Content.Server/Anomaly/AnomalySystem.Generator.cs b/Content.Server/Anomaly/AnomalySystem.Generator.cs
new file mode 100644
index 0000000000..cd37280270
--- /dev/null
+++ b/Content.Server/Anomaly/AnomalySystem.Generator.cs
@@ -0,0 +1,114 @@
+using Content.Server.Anomaly.Components;
+using Content.Server.Power.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Shared.Anomaly;
+using Content.Shared.Materials;
+using Robust.Shared.Map.Components;
+
+namespace Content.Server.Anomaly;
+
+///
+/// This handles anomalous vessel as well as
+/// the calculations for how many points they
+/// should produce.
+///
+public sealed partial class AnomalySystem
+{
+ ///
+ /// A multiplier applied to the grid bounds
+ /// to make the likelihood of it spawning outside
+ /// of the main station less likely.
+ ///
+ /// tl;dr anomalies only generate on the inner __% of the station.
+ ///
+ public const float GridBoundsMultiplier = 0.6f;
+
+ private void InitializeGenerator()
+ {
+ SubscribeLocalEvent(OnGeneratorBUIOpened);
+ SubscribeLocalEvent(OnGeneratorMaterialAmountChanged);
+ SubscribeLocalEvent(OnGenerateButtonPressed);
+ SubscribeLocalEvent(OnGeneratorPowerChanged);
+ }
+
+ private void OnGeneratorPowerChanged(EntityUid uid, AnomalyGeneratorComponent component, ref PowerChangedEvent args)
+ {
+ _ambient.SetAmbience(uid, args.Powered);
+ }
+
+ private void OnGeneratorBUIOpened(EntityUid uid, AnomalyGeneratorComponent component, BoundUIOpenedEvent args)
+ {
+ UpdateGeneratorUi(uid, component);
+ }
+
+ private void OnGeneratorMaterialAmountChanged(EntityUid uid, AnomalyGeneratorComponent component, ref MaterialAmountChangedEvent args)
+ {
+ UpdateGeneratorUi(uid, component);
+ }
+
+ private void OnGenerateButtonPressed(EntityUid uid, AnomalyGeneratorComponent component, AnomalyGeneratorGenerateButtonPressedEvent args)
+ {
+ TryGeneratorCreateAnomaly(uid, component);
+ }
+
+ public void UpdateGeneratorUi(EntityUid uid, AnomalyGeneratorComponent component)
+ {
+ var materialAmount = _material.GetMaterialAmount(uid, component.RequiredMaterial);
+
+ var state = new AnomalyGeneratorUserInterfaceState(component.CooldownEndTime, materialAmount, component.MaterialPerAnomaly);
+ _ui.TrySetUiState(uid, AnomalyGeneratorUiKey.Key, state);
+ }
+
+ public void TryGeneratorCreateAnomaly(EntityUid uid, AnomalyGeneratorComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (!this.IsPowered(uid, EntityManager))
+ return;
+
+ if (Timing.CurTime < component.CooldownEndTime)
+ return;
+
+ var grid = Transform(uid).GridUid;
+ if (grid == null)
+ return;
+
+ if (!_material.TryChangeMaterialAmount(uid, component.RequiredMaterial, -component.MaterialPerAnomaly))
+ return;
+
+ SpawnOnRandomGridLocation(grid.Value, component.SpawnerPrototype);
+ component.CooldownEndTime = Timing.CurTime + component.CooldownLength;
+ UpdateGeneratorUi(uid, component);
+ }
+
+ private void SpawnOnRandomGridLocation(EntityUid grid, string toSpawn)
+ {
+ if (!TryComp(grid, out var gridComp))
+ return;
+
+ var xform = Transform(grid);
+
+ var targetCoords = xform.Coordinates;
+ var (gridPos, _, gridMatrix) = _transform.GetWorldPositionRotationMatrix(xform);
+ var gridBounds = gridMatrix.TransformBox(gridComp.LocalAABB);
+
+ for (var i = 0; i < 25; i++)
+ {
+ var randomX = Random.Next((int) (gridBounds.Left * GridBoundsMultiplier), (int) (gridBounds.Right * GridBoundsMultiplier));
+ var randomY = Random.Next((int) (gridBounds.Bottom * GridBoundsMultiplier), (int) (gridBounds.Top * GridBoundsMultiplier));
+
+ var tile = new Vector2i(randomX - (int) gridPos.X, randomY - (int) gridPos.Y);
+ if (_atmosphere.IsTileSpace(grid, Transform(grid).MapUid, tile,
+ mapGridComp: gridComp) || _atmosphere.IsTileAirBlocked(grid, tile, mapGridComp: gridComp))
+ {
+ continue;
+ }
+
+ targetCoords = gridComp.GridTileToLocal(tile);
+ break;
+ }
+
+ Spawn(toSpawn, targetCoords);
+ }
+}
diff --git a/Content.Server/Anomaly/AnomalySystem.Scanner.cs b/Content.Server/Anomaly/AnomalySystem.Scanner.cs
new file mode 100644
index 0000000000..8c70810682
--- /dev/null
+++ b/Content.Server/Anomaly/AnomalySystem.Scanner.cs
@@ -0,0 +1,169 @@
+using Content.Server.Anomaly.Components;
+using Content.Server.DoAfter;
+using Content.Shared.Anomaly;
+using Content.Shared.Anomaly.Components;
+using Content.Shared.Interaction;
+using Robust.Server.GameObjects;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Anomaly;
+
+///
+/// This handles the anomaly scanner and it's UI updates.
+///
+public sealed partial class AnomalySystem
+{
+ private void InitializeScanner()
+ {
+ SubscribeLocalEvent(OnScannerUiOpened);
+ SubscribeLocalEvent(OnScannerAfterInteract);
+ SubscribeLocalEvent(OnScannerDoAfterFinished);
+ SubscribeLocalEvent(OnScannerDoAfterCancelled);
+
+ SubscribeLocalEvent(OnScannerAnomalyShutdown);
+ SubscribeLocalEvent(OnScannerAnomalySeverityChanged);
+ SubscribeLocalEvent(OnScannerAnomalyStabilityChanged);
+ SubscribeLocalEvent(OnScannerAnomalyHealthChanged);
+ }
+
+ private void OnScannerAnomalyShutdown(ref AnomalyShutdownEvent args)
+ {
+ foreach (var component in EntityQuery())
+ {
+ if (component.ScannedAnomaly != args.Anomaly)
+ continue;
+ _ui.TryCloseAll(component.Owner, AnomalyScannerUiKey.Key);
+ }
+ }
+
+ private void OnScannerAnomalySeverityChanged(ref AnomalySeverityChangedEvent args)
+ {
+ foreach (var component in EntityQuery())
+ {
+ if (component.ScannedAnomaly != args.Anomaly)
+ continue;
+ UpdateScannerUi(component.Owner, component);
+ }
+ }
+
+ private void OnScannerAnomalyStabilityChanged(ref AnomalyStabilityChangedEvent args)
+ {
+ foreach (var component in EntityQuery())
+ {
+ if (component.ScannedAnomaly != args.Anomaly)
+ continue;
+ UpdateScannerUi(component.Owner, component);
+ }
+ }
+
+ private void OnScannerAnomalyHealthChanged(ref AnomalyHealthChangedEvent args)
+ {
+ foreach (var component in EntityQuery())
+ {
+ if (component.ScannedAnomaly != args.Anomaly)
+ continue;
+ UpdateScannerUi(component.Owner, component);
+ }
+ }
+
+ private void OnScannerUiOpened(EntityUid uid, AnomalyScannerComponent component, BoundUIOpenedEvent args)
+ {
+ UpdateScannerUi(uid, component);
+ }
+
+ private void OnScannerAfterInteract(EntityUid uid, AnomalyScannerComponent component, AfterInteractEvent args)
+ {
+ if (component.TokenSource != null)
+ return;
+
+ if (args.Target is not { } target)
+ return;
+ if (!HasComp(target))
+ return;
+
+ component.TokenSource = new();
+ _doAfter.DoAfter(new DoAfterEventArgs(args.User, component.ScanDoAfterDuration, component.TokenSource.Token, target, uid)
+ {
+ DistanceThreshold = 2f,
+ UsedFinishedEvent = new AnomalyScanFinishedEvent(target, args.User),
+ UsedCancelledEvent = new AnomalyScanCancelledEvent()
+ });
+ }
+
+ private void OnScannerDoAfterFinished(EntityUid uid, AnomalyScannerComponent component, AnomalyScanFinishedEvent args)
+ {
+ component.TokenSource = null;
+
+ Audio.PlayPvs(component.CompleteSound, uid);
+ _popup.PopupEntity(Loc.GetString("anomaly-scanner-component-scan-complete"), uid);
+ UpdateScannerWithNewAnomaly(uid, args.Anomaly, component);
+
+ if (TryComp(args.User, out var actor))
+ _ui.TryOpen(uid, AnomalyScannerUiKey.Key, actor.PlayerSession);
+ }
+
+ private void OnScannerDoAfterCancelled(EntityUid uid, AnomalyScannerComponent component, AnomalyScanCancelledEvent args)
+ {
+ component.TokenSource = null;
+ }
+
+ public void UpdateScannerUi(EntityUid uid, AnomalyScannerComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ TimeSpan? nextPulse = null;
+ if (TryComp(component.ScannedAnomaly, out var anomalyComponent))
+ nextPulse = anomalyComponent.NextPulseTime;
+
+ var state = new AnomalyScannerUserInterfaceState(GetScannerMessage(component), nextPulse);
+ _ui.TrySetUiState(uid, AnomalyScannerUiKey.Key, state);
+ }
+
+ public void UpdateScannerWithNewAnomaly(EntityUid scanner, EntityUid anomaly, AnomalyScannerComponent? scannerComp = null, AnomalyComponent? anomalyComp = null)
+ {
+ if (!Resolve(scanner, ref scannerComp) || !Resolve(anomaly, ref anomalyComp))
+ return;
+
+ scannerComp.ScannedAnomaly = anomaly;
+ UpdateScannerUi(scanner, scannerComp);
+ }
+
+ public FormattedMessage GetScannerMessage(AnomalyScannerComponent component)
+ {
+ var msg = new FormattedMessage();
+ if (component.ScannedAnomaly is not { } anomaly || !TryComp(anomaly, out var anomalyComp))
+ {
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-no-anomaly"));
+ return msg;
+ }
+
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-severity-percentage", ("percent", anomalyComp.Severity.ToString("P"))));
+ msg.PushNewline();
+ string stateLoc;
+ if (anomalyComp.Stability < anomalyComp.DecayThreshold)
+ stateLoc = Loc.GetString("anomaly-scanner-stability-low");
+ else if (anomalyComp.Stability > anomalyComp.GrowthThreshold)
+ stateLoc = Loc.GetString("anomaly-scanner-stability-high");
+ else
+ stateLoc = Loc.GetString("anomaly-scanner-stability-medium");
+ msg.AddMarkup(stateLoc);
+ msg.PushNewline();
+
+ var points = GetAnomalyPointValue(anomaly, anomalyComp) / 10 * 10; //round to tens place
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-point-output", ("point", points)));
+ msg.PushNewline();
+ msg.PushNewline();
+
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-particle-readout"));
+ msg.PushNewline();
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-particle-danger", ("type", GetParticleLocale(anomalyComp.SeverityParticleType))));
+ msg.PushNewline();
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-particle-unstable", ("type", GetParticleLocale(anomalyComp.DestabilizingParticleType))));
+ msg.PushNewline();
+ msg.AddMarkup(Loc.GetString("anomaly-scanner-particle-containment", ("type", GetParticleLocale(anomalyComp.WeakeningParticleType))));
+
+ //The timer at the end here is actually added in the ui itself.
+ return msg;
+ }
+}
diff --git a/Content.Server/Anomaly/AnomalySystem.Vessel.cs b/Content.Server/Anomaly/AnomalySystem.Vessel.cs
new file mode 100644
index 0000000000..dabd2528fb
--- /dev/null
+++ b/Content.Server/Anomaly/AnomalySystem.Vessel.cs
@@ -0,0 +1,129 @@
+using Content.Server.Anomaly.Components;
+using Content.Server.Construction;
+using Content.Server.Power.EntitySystems;
+using Content.Shared.Anomaly;
+using Content.Shared.Anomaly.Components;
+using Content.Shared.Examine;
+using Content.Shared.Interaction;
+using Content.Shared.Research.Components;
+
+namespace Content.Server.Anomaly;
+
+///
+/// This handles anomalous vessel as well as
+/// the calculations for how many points they
+/// should produce.
+///
+public sealed partial class AnomalySystem
+{
+ private void InitializeVessel()
+ {
+ SubscribeLocalEvent(OnVesselShutdown);
+ SubscribeLocalEvent(OnVesselMapInit);
+ SubscribeLocalEvent(OnRefreshParts);
+ SubscribeLocalEvent(OnVesselInteractUsing);
+ SubscribeLocalEvent(OnExamined);
+ SubscribeLocalEvent(OnVesselGetPointsPerSecond);
+ SubscribeLocalEvent(OnVesselAnomalyShutdown);
+ }
+
+ private void OnExamined(EntityUid uid, AnomalyVesselComponent component, ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ args.PushText(component.Anomaly == null
+ ? Loc.GetString("anomaly-vessel-component-not-assigned")
+ : Loc.GetString("anomaly-vessel-component-assigned"));
+ }
+
+ private void OnVesselShutdown(EntityUid uid, AnomalyVesselComponent component, ComponentShutdown args)
+ {
+ if (component.Anomaly is not { } anomaly)
+ return;
+
+ if (!TryComp(anomaly, out var anomalyComp))
+ return;
+
+ anomalyComp.ConnectedVessel = null;
+ }
+
+ private void OnVesselMapInit(EntityUid uid, AnomalyVesselComponent component, MapInitEvent args)
+ {
+ UpdateVesselAppearance(uid, component);
+ }
+
+ private void OnRefreshParts(EntityUid uid, AnomalyVesselComponent component, RefreshPartsEvent args)
+ {
+ var modifierRating = args.PartRatings[component.MachinePartPointModifier] - 1;
+ component.PointMultiplier = MathF.Pow(component.PartRatingPointModifier, modifierRating);
+ }
+
+ private void OnVesselInteractUsing(EntityUid uid, AnomalyVesselComponent component, InteractUsingEvent args)
+ {
+ if (component.Anomaly != null ||
+ !TryComp(args.Used, out var scanner) ||
+ scanner.ScannedAnomaly is not {} anomaly)
+ {
+ return;
+ }
+
+ if (!TryComp(anomaly, out var anomalyComponent) || anomalyComponent.ConnectedVessel != null)
+ return;
+
+ component.Anomaly = scanner.ScannedAnomaly;
+ anomalyComponent.ConnectedVessel = uid;
+ UpdateVesselAppearance(uid, component);
+ _popup.PopupEntity(Loc.GetString("anomaly-vessel-component-anomaly-assigned"), uid);
+ }
+
+ private void OnVesselGetPointsPerSecond(EntityUid uid, AnomalyVesselComponent component, ref ResearchServerGetPointsPerSecondEvent args)
+ {
+ if (!this.IsPowered(uid, EntityManager) || component.Anomaly is not {} anomaly)
+ {
+ args.Points = 0;
+ return;
+ }
+
+ args.Points += (int) (GetAnomalyPointValue(anomaly) * component.PointMultiplier);
+ }
+
+ private void OnVesselAnomalyShutdown(ref AnomalyShutdownEvent args)
+ {
+ foreach (var component in EntityQuery())
+ {
+ var ent = component.Owner;
+
+ if (args.Anomaly != component.Anomaly)
+ continue;
+
+ component.Anomaly = null;
+ UpdateVesselAppearance(ent, component);
+
+ if (!args.Supercritical)
+ continue;
+ _explosion.TriggerExplosive(ent);
+ }
+ }
+
+ ///
+ /// Updates the appearance of an anomaly vessel
+ /// based on whether or not it has an anomaly
+ ///
+ ///
+ ///
+ public void UpdateVesselAppearance(EntityUid uid, AnomalyVesselComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var on = component.Anomaly != null;
+
+ Appearance.SetData(uid, AnomalyVesselVisuals.HasAnomaly, on);
+ if (TryComp(uid, out var pointLightComponent))
+ {
+ pointLightComponent.Enabled = on;
+ }
+ _ambient.SetAmbience(uid, on);
+ }
+}
diff --git a/Content.Server/Anomaly/AnomalySystem.cs b/Content.Server/Anomaly/AnomalySystem.cs
new file mode 100644
index 0000000000..ed31ab7510
--- /dev/null
+++ b/Content.Server/Anomaly/AnomalySystem.cs
@@ -0,0 +1,127 @@
+using Content.Server.Anomaly.Components;
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.Audio;
+using Content.Server.DoAfter;
+using Content.Server.Explosion.EntitySystems;
+using Content.Server.Materials;
+using Content.Server.Popups;
+using Content.Shared.Anomaly;
+using Content.Shared.Anomaly.Components;
+using Robust.Server.GameObjects;
+using Robust.Shared.Physics.Events;
+using Robust.Shared.Random;
+namespace Content.Server.Anomaly;
+
+///
+/// This handles logic and interactions relating to
+///
+public sealed partial class AnomalySystem : SharedAnomalySystem
+{
+ [Dependency] private readonly AmbientSoundSystem _ambient = default!;
+ [Dependency] private readonly AtmosphereSystem _atmosphere = default!;
+ [Dependency] private readonly DoAfterSystem _doAfter = default!;
+ [Dependency] private readonly ExplosionSystem _explosion = default!;
+ [Dependency] private readonly MaterialStorageSystem _material = default!;
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
+ [Dependency] private readonly UserInterfaceSystem _ui = default!;
+
+ public const float MinParticleVariation = 0.8f;
+ public const float MaxParticleVariation = 1.2f;
+
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnMapInit);
+ SubscribeLocalEvent(OnShutdown);
+ SubscribeLocalEvent(OnStartCollide);
+
+ InitializeGenerator();
+ InitializeScanner();
+ InitializeVessel();
+ }
+
+ private void OnMapInit(EntityUid uid, AnomalyComponent component, MapInitEvent args)
+ {
+ component.NextPulseTime = Timing.CurTime + GetPulseLength(component) * 3; // longer the first time
+ ChangeAnomalyStability(uid, Random.NextFloat(component.InitialStabilityRange.Item1 , component.InitialStabilityRange.Item2), component);
+ ChangeAnomalySeverity(uid, Random.NextFloat(component.InitialSeverityRange.Item1, component.InitialSeverityRange.Item2), component);
+
+ var particles = new List
+ { AnomalousParticleType.Delta, AnomalousParticleType.Epsilon, AnomalousParticleType.Zeta };
+ component.SeverityParticleType = Random.PickAndTake(particles);
+ component.DestabilizingParticleType = Random.PickAndTake(particles);
+ component.WeakeningParticleType = Random.PickAndTake(particles);
+ }
+
+ private void OnShutdown(EntityUid uid, AnomalyComponent component, ComponentShutdown args)
+ {
+ EndAnomaly(uid, component);
+ }
+
+ private void OnStartCollide(EntityUid uid, AnomalyComponent component, ref StartCollideEvent args)
+ {
+ if (!TryComp(args.OtherFixture.Body.Owner, out var particleComponent))
+ return;
+
+ if (args.OtherFixture.ID != particleComponent.FixtureId)
+ return;
+
+ // small function to randomize because it's easier to read like this
+ float VaryValue(float v) => v * Random.NextFloat(MinParticleVariation, MaxParticleVariation);
+
+ if (particleComponent.ParticleType == component.DestabilizingParticleType)
+ {
+ ChangeAnomalyStability(uid, VaryValue(component.StabilityPerDestabilizingHit), component);
+ }
+ else if (particleComponent.ParticleType == component.SeverityParticleType)
+ {
+ ChangeAnomalySeverity(uid, VaryValue(component.SeverityPerSeverityHit), component);
+ }
+ else if (particleComponent.ParticleType == component.WeakeningParticleType)
+ {
+ ChangeAnomalyHealth(uid, VaryValue(component.HealthPerWeakeningeHit), component);
+ ChangeAnomalyStability(uid, VaryValue(component.StabilityPerWeakeningeHit), component);
+ }
+ }
+
+ ///
+ /// Gets the amount of research points generated per second for an anomaly.
+ ///
+ ///
+ ///
+ /// The amount of points
+ public int GetAnomalyPointValue(EntityUid anomaly, AnomalyComponent? component = null)
+ {
+ if (!Resolve(anomaly, ref component, false))
+ return 0;
+
+ var multiplier = 1f;
+ if (component.Stability > component.GrowthThreshold)
+ multiplier = 1.25f; //more points for unstable
+ else if (component.Stability < component.DecayThreshold)
+ multiplier = 0.75f; //less points if it's dying
+
+ //penalty of up to 50% based on health
+ multiplier *= MathF.Pow(1.5f, component.Health) - 0.5f;
+
+ return (int) ((component.MaxPointsPerSecond - component.MinPointsPerSecond) * component.Severity * multiplier);
+ }
+
+ ///
+ /// Gets the localized name of a particle.
+ ///
+ ///
+ ///
+ public string GetParticleLocale(AnomalousParticleType type)
+ {
+ return type switch
+ {
+ AnomalousParticleType.Delta => Loc.GetString("anomaly-particles-delta"),
+ AnomalousParticleType.Epsilon => Loc.GetString("anomaly-particles-epsilon"),
+ AnomalousParticleType.Zeta => Loc.GetString("anomaly-particles-zeta"),
+ _ => throw new ArgumentOutOfRangeException()
+ };
+ }
+}
diff --git a/Content.Server/Anomaly/Components/AnomalousParticleComponent.cs b/Content.Server/Anomaly/Components/AnomalousParticleComponent.cs
new file mode 100644
index 0000000000..195fe5a941
--- /dev/null
+++ b/Content.Server/Anomaly/Components/AnomalousParticleComponent.cs
@@ -0,0 +1,23 @@
+using Content.Shared.Anomaly;
+
+namespace Content.Server.Anomaly.Components;
+
+///
+/// This is used for projectiles which affect anomalies through colliding with them.
+///
+[RegisterComponent]
+public sealed class AnomalousParticleComponent : Component
+{
+ ///
+ /// The type of particle that the projectile
+ /// imbues onto the anomaly on contact.
+ ///
+ [DataField("particleType", required: true)]
+ public AnomalousParticleType ParticleType;
+
+ ///
+ /// The fixture that's checked on collision.
+ ///
+ [DataField("fixtureId")]
+ public string FixtureId = "projectile";
+}
diff --git a/Content.Server/Anomaly/Components/AnomalyGeneratorComponent.cs b/Content.Server/Anomaly/Components/AnomalyGeneratorComponent.cs
new file mode 100644
index 0000000000..4c9fc39442
--- /dev/null
+++ b/Content.Server/Anomaly/Components/AnomalyGeneratorComponent.cs
@@ -0,0 +1,44 @@
+using Content.Shared.Materials;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.Anomaly.Components;
+
+///
+/// This is used for a machine that is able to generate
+/// anomalies randomly on the station.
+///
+[RegisterComponent]
+public sealed class AnomalyGeneratorComponent : Component
+{
+ ///
+ /// The time at which the cooldown for generating another anomaly will be over
+ ///
+ [DataField("cooldownEndTime", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan CooldownEndTime = TimeSpan.Zero;
+
+ ///
+ /// The cooldown between generating anomalies.
+ ///
+ [DataField("cooldownLength"), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan CooldownLength = TimeSpan.FromMinutes(5);
+
+ ///
+ /// The material needed to generate an anomaly
+ ///
+ [DataField("requiredMaterial", customTypeSerializer: typeof(PrototypeIdSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public string RequiredMaterial = "Plasma";
+
+ ///
+ /// The amount of material needed to generate a single anomaly
+ ///
+ [DataField("materialPerAnomaly"), ViewVariables(VVAccess.ReadWrite)]
+ public int MaterialPerAnomaly = 1500; // a bit less than a stack of plasma
+
+ ///
+ /// The random anomaly spawner entity
+ ///
+ [DataField("spawnerPrototype", customTypeSerializer: typeof(PrototypeIdSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public string SpawnerPrototype = "RandomAnomalySpawner";
+}
diff --git a/Content.Server/Anomaly/Components/AnomalyScannerComponent.cs b/Content.Server/Anomaly/Components/AnomalyScannerComponent.cs
new file mode 100644
index 0000000000..6fd8c1787d
--- /dev/null
+++ b/Content.Server/Anomaly/Components/AnomalyScannerComponent.cs
@@ -0,0 +1,49 @@
+using System.Threading;
+using Robust.Shared.Audio;
+
+namespace Content.Server.Anomaly.Components;
+
+///
+/// This is used for scanning anomalies and
+/// displaying information about them in the ui
+///
+[RegisterComponent]
+public sealed class AnomalyScannerComponent : Component
+{
+ ///
+ /// The anomaly that was last scanned by this scanner.
+ ///
+ [ViewVariables]
+ public EntityUid? ScannedAnomaly;
+
+ ///
+ /// How long the scan takes
+ ///
+ [DataField("scanDoAfterDuration")]
+ public float ScanDoAfterDuration = 5;
+
+ public CancellationTokenSource? TokenSource;
+
+ ///
+ /// The sound plays when the scan finished
+ ///
+ [DataField("completeSound")]
+ public SoundSpecifier? CompleteSound = new SoundPathSpecifier("/Audio/Items/beep.ogg");
+}
+
+public sealed class AnomalyScanFinishedEvent : EntityEventArgs
+{
+ public EntityUid Anomaly;
+
+ public EntityUid User;
+
+ public AnomalyScanFinishedEvent(EntityUid anomaly, EntityUid user)
+ {
+ Anomaly = anomaly;
+ User = user;
+ }
+}
+
+public sealed class AnomalyScanCancelledEvent : EntityEventArgs
+{
+}
diff --git a/Content.Server/Anomaly/Components/AnomalyVesselComponent.cs b/Content.Server/Anomaly/Components/AnomalyVesselComponent.cs
new file mode 100644
index 0000000000..c833cf636b
--- /dev/null
+++ b/Content.Server/Anomaly/Components/AnomalyVesselComponent.cs
@@ -0,0 +1,40 @@
+using Content.Shared.Construction.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.Anomaly.Components;
+
+///
+/// Anomaly Vessels can have an anomaly "stored" in them
+/// by interacting on them with an anomaly scanner. Then,
+/// they generate points for the selected server based on
+/// the anomaly's stability and severity.
+///
+[RegisterComponent]
+public sealed class AnomalyVesselComponent : Component
+{
+ ///
+ /// The anomaly that the vessel is storing.
+ /// Can be null.
+ ///
+ [ViewVariables]
+ public EntityUid? Anomaly;
+
+ ///
+ /// A multiplier applied to the amount of points generated.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public float PointMultiplier = 1;
+
+ ///
+ /// The machine part that affects the point multiplier of the vessel
+ ///
+ [DataField("machinePartPointModifier", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string MachinePartPointModifier = "ScanningModule";
+
+ ///
+ /// A value used to scale the point multiplier
+ /// with the corresponding part rating.
+ ///
+ [DataField("partRatingPointModifier")]
+ public float PartRatingPointModifier = 1.5f;
+}
diff --git a/Content.Server/Anomaly/Effects/ElectricityAnomalySystem.cs b/Content.Server/Anomaly/Effects/ElectricityAnomalySystem.cs
new file mode 100644
index 0000000000..55c7b1bfa5
--- /dev/null
+++ b/Content.Server/Anomaly/Effects/ElectricityAnomalySystem.cs
@@ -0,0 +1,61 @@
+using Content.Server.Electrocution;
+using Content.Server.Lightning;
+using Content.Server.Power.Components;
+using Content.Shared.Anomaly.Components;
+using Content.Shared.Anomaly.Effects.Components;
+using Content.Shared.Mobs.Components;
+using Content.Shared.StatusEffect;
+using Robust.Shared.Random;
+
+namespace Content.Server.Anomaly.Effects;
+
+public sealed class ElectricityAnomalySystem : EntitySystem
+{
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly LightningSystem _lightning = default!;
+ [Dependency] private readonly ElectrocutionSystem _electrocution = default!;
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnPulse);
+ SubscribeLocalEvent(OnSupercritical);
+ }
+
+ private void OnPulse(EntityUid uid, ElectricityAnomalyComponent component, ref AnomalyPulseEvent args)
+ {
+ var range = component.MaxElectrocuteRange * args.Stabiltiy;
+ var damage = (int) (component.MaxElectrocuteDamage * args.Severity);
+ var duration = component.MaxElectrocuteDuration * args.Severity;
+
+ var xform = Transform(uid);
+ foreach (var comp in _lookup.GetComponentsInRange(xform.MapPosition, range))
+ {
+ var ent = comp.Owner;
+
+ _electrocution.TryDoElectrocution(ent, uid, damage, duration, true, statusEffects: comp, ignoreInsulation: true);
+ }
+ }
+
+ private void OnSupercritical(EntityUid uid, ElectricityAnomalyComponent component, ref AnomalySupercriticalEvent args)
+ {
+ var poweredQuery = GetEntityQuery();
+ var mobQuery = GetEntityQuery();
+ var validEnts = new HashSet();
+ foreach (var ent in _lookup.GetEntitiesInRange(uid, component.MaxElectrocuteRange * 2))
+ {
+ if (mobQuery.HasComponent(ent))
+ validEnts.Add(ent);
+
+ if (_random.Prob(0.1f) && poweredQuery.HasComponent(ent))
+ validEnts.Add(ent);
+ }
+
+ // goodbye, sweet perf
+ foreach (var ent in validEnts)
+ {
+ _lightning.ShootLightning(uid, ent);
+ }
+ }
+}
diff --git a/Content.Server/Anomaly/Effects/GravityAnomalySystem.cs b/Content.Server/Anomaly/Effects/GravityAnomalySystem.cs
new file mode 100644
index 0000000000..a928add35b
--- /dev/null
+++ b/Content.Server/Anomaly/Effects/GravityAnomalySystem.cs
@@ -0,0 +1,39 @@
+using Content.Server.Singularity.Components;
+using Content.Shared.Anomaly.Components;
+using Content.Shared.Anomaly.Effects;
+using Content.Shared.Anomaly.Effects.Components;
+using Content.Shared.Radiation.Components;
+
+namespace Content.Server.Anomaly.Effects;
+
+///
+/// This handles logic and events relating to and
+///
+public sealed class GravityAnomalySystem : SharedGravityAnomalySystem
+{
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnSeverityChanged);
+ SubscribeLocalEvent(OnStabilityChanged);
+ }
+
+ private void OnSeverityChanged(EntityUid uid, GravityAnomalyComponent component, ref AnomalySeverityChangedEvent args)
+ {
+ if (TryComp(uid, out var radSource))
+ radSource.Intensity = component.MaxRadiationIntensity * args.Severity;
+
+ if (!TryComp(uid, out var gravityWell))
+ return;
+ var accel = (component.MaxAccel - component.MinAccel) * args.Severity + component.MinAccel;
+ gravityWell.BaseRadialAcceleration = accel;
+ gravityWell.BaseTangentialAcceleration = accel * 0.2f;
+ }
+
+ private void OnStabilityChanged(EntityUid uid, GravityAnomalyComponent component, ref AnomalyStabilityChangedEvent args)
+ {
+ if (TryComp(uid, out var gravityWell))
+ gravityWell.MaxRange = component.MaxGravityWellRange * args.Stability;
+ }
+}
diff --git a/Content.Server/Anomaly/Effects/PyroclasticAnomalySystem.cs b/Content.Server/Anomaly/Effects/PyroclasticAnomalySystem.cs
new file mode 100644
index 0000000000..87508802a4
--- /dev/null
+++ b/Content.Server/Anomaly/Effects/PyroclasticAnomalySystem.cs
@@ -0,0 +1,100 @@
+using Content.Server.Atmos.Components;
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.Interaction;
+using Content.Shared.Anomaly.Components;
+using Content.Shared.Anomaly.Effects.Components;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map;
+
+namespace Content.Server.Anomaly.Effects;
+
+///
+/// This handles and the events from
+///
+public sealed class PyroclasticAnomalySystem : EntitySystem
+{
+ [Dependency] private readonly AtmosphereSystem _atmosphere = default!;
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly FlammableSystem _flammable = default!;
+ [Dependency] private readonly InteractionSystem _interaction = default!;
+ [Dependency] private readonly TransformSystem _xform = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnPulse);
+ SubscribeLocalEvent(OnSupercritical);
+ }
+
+ private void OnPulse(EntityUid uid, PyroclasticAnomalyComponent component, ref AnomalyPulseEvent args)
+ {
+ var xform = Transform(uid);
+ var ignitionRadius = component.MaximumIgnitionRadius * args.Stabiltiy;
+ IgniteNearby(xform.Coordinates, args.Severity, ignitionRadius);
+ }
+
+ private void OnSupercritical(EntityUid uid, PyroclasticAnomalyComponent component, ref AnomalySupercriticalEvent args)
+ {
+ var xform = Transform(uid);
+ var grid = xform.GridUid;
+ var map = xform.MapUid;
+
+ var indices = _xform.GetGridOrMapTilePosition(uid, xform);
+ var mixture = _atmosphere.GetTileMixture(grid, map, indices, true);
+
+ if (mixture == null)
+ return;
+ mixture.AdjustMoles(component.SupercriticalGas, component.SupercriticalMoleAmount);
+ if (grid is { })
+ {
+ foreach (var ind in _atmosphere.GetAdjacentTiles(grid.Value, indices))
+ {
+ var mix = _atmosphere.GetTileMixture(grid, map, indices, true);
+ if (mix is not {})
+ continue;
+ mix.AdjustMoles(component.SupercriticalGas, component.SupercriticalMoleAmount);
+ mix.Temperature += component.HotspotExposeTemperature;
+ _atmosphere.HotspotExpose(grid.Value, indices, component.HotspotExposeTemperature, mix?.Volume ?? component.SupercriticalMoleAmount, true);
+ }
+ }
+ IgniteNearby(xform.Coordinates, 1, component.MaximumIgnitionRadius * 2);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ foreach (var (pyro, anom, xform) in EntityQuery())
+ {
+ var ent = pyro.Owner;
+
+ var grid = xform.GridUid;
+ var map = xform.MapUid;
+ var indices = _xform.GetGridOrMapTilePosition(ent, xform);
+ var mixture = _atmosphere.GetTileMixture(grid, map, indices, true);
+ if (mixture is { })
+ {
+ mixture.Temperature += pyro.HeatPerSecond * anom.Severity * frameTime;
+ }
+
+ if (grid != null && anom.Severity > pyro.AnomalyHotspotThreshold)
+ {
+ _atmosphere.HotspotExpose(grid.Value, indices, pyro.HotspotExposeTemperature, pyro.HotspotExposeVolume, true);
+ }
+ }
+ }
+
+ public void IgniteNearby(EntityCoordinates coordinates, float severity, float radius)
+ {
+ foreach (var flammable in _lookup.GetComponentsInRange(coordinates, radius))
+ {
+ var ent = flammable.Owner;
+ if (!_interaction.InRangeUnobstructed(coordinates.ToMap(EntityManager), ent, -1))
+ continue;
+
+ var stackAmount = 1 + (int) (severity / 0.25f);
+ _flammable.AdjustFireStacks(ent, stackAmount, flammable);
+ _flammable.Ignite(ent, flammable);
+ }
+ }
+}
diff --git a/Content.Server/Beam/BeamSystem.cs b/Content.Server/Beam/BeamSystem.cs
index dd2fb798b8..0f0cf6fac3 100644
--- a/Content.Server/Beam/BeamSystem.cs
+++ b/Content.Server/Beam/BeamSystem.cs
@@ -1,14 +1,11 @@
using Content.Server.Beam.Components;
-using Content.Server.Lightning;
using Content.Shared.Beam;
using Content.Shared.Beam.Components;
-using Content.Shared.Interaction;
using Content.Shared.Physics;
using Robust.Shared.Map;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Collision.Shapes;
using Robust.Shared.Physics.Components;
-using Robust.Shared.Physics.Dynamics;
using Robust.Shared.Physics.Systems;
namespace Content.Server.Beam;
@@ -17,8 +14,8 @@ public sealed class BeamSystem : SharedBeamSystem
{
[Dependency] private readonly FixtureSystem _fixture = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedBroadphaseSystem _broadphase = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
- [Dependency] private readonly SharedTransformSystem _transformSystem = default!;
public override void Initialize()
{
@@ -79,52 +76,52 @@ public sealed class BeamSystem : SharedBeamSystem
var shape = new EdgeShape(distanceCorrection, new Vector2(0,0));
var distanceLength = distanceCorrection.Length;
- if (TryComp(ent, out var physics) && TryComp(ent, out var beam))
+ if (!TryComp(ent, out var physics) || !TryComp(ent, out var beam))
+ return;
+ FixturesComponent? manager = null;
+ _fixture.TryCreateFixture(
+ ent,
+ shape,
+ "BeamBody",
+ hard: false,
+ collisionMask: (int)CollisionGroup.ItemMask,
+ collisionLayer: (int)CollisionGroup.MobLayer,
+ manager: manager,
+ body: physics);
+
+ _physics.SetBodyType(ent, BodyType.Dynamic, manager: manager, body: physics);
+ _physics.SetCanCollide(ent, true, manager: manager, body: physics);
+ _broadphase.RegenerateContacts(physics, manager);
+
+ var beamVisualizerEvent = new BeamVisualizerEvent(ent, distanceLength, userAngle, bodyState, shader);
+ RaiseNetworkEvent(beamVisualizerEvent);
+
+ if (controller != null)
+ beam.VirtualBeamController = controller;
+
+ else
{
- FixturesComponent? manager = null;
- _fixture.TryCreateFixture(
- ent,
- shape,
- "BeamBody",
- hard: false,
- collisionMask: (int)CollisionGroup.ItemMask,
- collisionLayer: (int)CollisionGroup.MobLayer,
- manager: manager,
- body: physics);
+ var controllerEnt = Spawn("VirtualBeamEntityController", beamSpawnPos);
+ beam.VirtualBeamController = controllerEnt;
- _physics.SetBodyType(ent, BodyType.Dynamic, manager: manager, body: physics);
- _physics.SetCanCollide(ent, true, manager: manager, body: physics);
+ _audio.PlayPvs(beam.Sound, beam.Owner);
- var beamVisualizerEvent = new BeamVisualizerEvent(ent, distanceLength, userAngle, bodyState, shader);
- RaiseNetworkEvent(beamVisualizerEvent);
-
- if (controller != null)
- beam.VirtualBeamController = controller;
-
- else
- {
- var controllerEnt = Spawn("VirtualBeamEntityController", beamSpawnPos);
- beam.VirtualBeamController = controllerEnt;
-
- _audio.PlayPvs(beam.Sound, beam.Owner);
-
- var beamControllerCreatedEvent = new BeamControllerCreatedEvent(ent, controllerEnt);
- RaiseLocalEvent(controllerEnt, beamControllerCreatedEvent);
- }
-
- //Create the rest of the beam, sprites handled through the BeamVisualizerEvent
- for (int i = 0; i < distanceLength-1; i++)
- {
- beamSpawnPos = beamSpawnPos.Offset(calculatedDistance.Normalized);
- var newEnt = Spawn(prototype, beamSpawnPos);
-
- var ev = new BeamVisualizerEvent(newEnt, distanceLength, userAngle, bodyState, shader);
- RaiseNetworkEvent(ev);
- }
-
- var beamFiredEvent = new BeamFiredEvent(ent);
- RaiseLocalEvent(beam.VirtualBeamController.Value, beamFiredEvent);
+ var beamControllerCreatedEvent = new BeamControllerCreatedEvent(ent, controllerEnt);
+ RaiseLocalEvent(controllerEnt, beamControllerCreatedEvent);
}
+
+ //Create the rest of the beam, sprites handled through the BeamVisualizerEvent
+ for (var i = 0; i < distanceLength-1; i++)
+ {
+ beamSpawnPos = beamSpawnPos.Offset(calculatedDistance.Normalized);
+ var newEnt = Spawn(prototype, beamSpawnPos);
+
+ var ev = new BeamVisualizerEvent(newEnt, distanceLength, userAngle, bodyState, shader);
+ RaiseNetworkEvent(ev);
+ }
+
+ var beamFiredEvent = new BeamFiredEvent(ent);
+ RaiseLocalEvent(beam.VirtualBeamController.Value, beamFiredEvent);
}
///
diff --git a/Content.Server/Singularity/EntitySystems/EmitterSystem.cs b/Content.Server/Singularity/EntitySystems/EmitterSystem.cs
index 44986ab518..6393df7ee6 100644
--- a/Content.Server/Singularity/EntitySystems/EmitterSystem.cs
+++ b/Content.Server/Singularity/EntitySystems/EmitterSystem.cs
@@ -7,16 +7,19 @@ using Content.Server.Projectiles;
using Content.Server.Storage.Components;
using Content.Server.Weapons.Ranged.Systems;
using Content.Shared.Database;
+using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Popups;
using Content.Shared.Projectiles;
using Content.Shared.Singularity.Components;
using Content.Shared.Singularity.EntitySystems;
+using Content.Shared.Verbs;
using Content.Shared.Weapons.Ranged.Components;
using JetBrains.Annotations;
using Robust.Shared.Map;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Components;
+using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Utility;
using Timer = Robust.Shared.Timing.Timer;
@@ -27,6 +30,7 @@ namespace Content.Server.Singularity.EntitySystems
public sealed class EmitterSystem : SharedEmitterSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
@@ -38,14 +42,28 @@ namespace Content.Server.Singularity.EntitySystems
base.Initialize();
SubscribeLocalEvent(ReceivedChanged);
+ SubscribeLocalEvent(OnApcChanged);
SubscribeLocalEvent(OnInteractHand);
+ SubscribeLocalEvent>(OnGetVerb);
+ SubscribeLocalEvent(OnExamined);
SubscribeLocalEvent(OnRefreshParts);
SubscribeLocalEvent(OnUpgradeExamine);
+ SubscribeLocalEvent(OnAnchorStateChanged);
+ }
+
+ private void OnAnchorStateChanged(EntityUid uid, EmitterComponent component, ref AnchorStateChangedEvent args)
+ {
+ if (args.Anchored)
+ return;
+
+ SwitchOff(component);
}
private void OnInteractHand(EntityUid uid, EmitterComponent component, InteractHandEvent args)
{
- args.Handled = true;
+ if (args.Handled)
+ return;
+
if (EntityManager.TryGetComponent(uid, out LockComponent? lockComp) && lockComp.Locked)
{
_popup.PopupEntity(Loc.GetString("comp-emitter-access-locked",
@@ -71,6 +89,7 @@ namespace Content.Server.Singularity.EntitySystems
_adminLogger.Add(LogType.Emitter,
component.IsOn ? LogImpact.Medium : LogImpact.High,
$"{ToPrettyString(args.User):player} toggled {ToPrettyString(uid):emitter}");
+ args.Handled = true;
}
else
{
@@ -79,6 +98,47 @@ namespace Content.Server.Singularity.EntitySystems
}
}
+ private void OnGetVerb(EntityUid uid, EmitterComponent component, GetVerbsEvent args)
+ {
+ if (!args.CanAccess || !args.CanInteract || args.Hands == null)
+ return;
+
+ if (TryComp(uid, out var lockComp) && lockComp.Locked)
+ return;
+
+ if (component.SelectableTypes.Count < 2)
+ return;
+
+ foreach (var type in component.SelectableTypes)
+ {
+ var proto = _prototype.Index(type);
+
+ var v = new Verb
+ {
+ Priority = 1,
+ Category = VerbCategory.SelectType,
+ Text = proto.Name,
+ Disabled = type == component.BoltType,
+ Impact = LogImpact.Medium,
+ DoContactInteraction = true,
+ Act = () =>
+ {
+ component.BoltType = type;
+ _popup.PopupEntity(Loc.GetString("emitter-component-type-set", ("type", proto.Name)), uid);
+ }
+ };
+ args.Verbs.Add(v);
+ }
+ }
+
+ private void OnExamined(EntityUid uid, EmitterComponent component, ExaminedEvent args)
+ {
+ if (component.SelectableTypes.Count < 2)
+ return;
+ var proto = _prototype.Index(component.BoltType);
+ args.Message.AddText(Loc.GetString("emitter-component-current-type", ("type", proto.Name)));
+ }
+
private void ReceivedChanged(
EntityUid uid,
EmitterComponent component,
@@ -99,6 +159,23 @@ namespace Content.Server.Singularity.EntitySystems
}
}
+ private void OnApcChanged(EntityUid uid, EmitterComponent component, ref PowerChangedEvent args)
+ {
+ if (!component.IsOn)
+ {
+ return;
+ }
+
+ if (!args.Powered)
+ {
+ PowerOff(component);
+ }
+ else
+ {
+ PowerOn(component);
+ }
+ }
+
private void OnRefreshParts(EntityUid uid, EmitterComponent component, RefreshPartsEvent args)
{
var powerUseRating = args.PartRatings[component.MachinePartPowerUse];
@@ -122,7 +199,9 @@ namespace Content.Server.Singularity.EntitySystems
{
component.IsOn = false;
if (TryComp(component.Owner, out var powerConsumer))
- powerConsumer.DrawRate = 0;
+ powerConsumer.DrawRate = 1; // this needs to be not 0 so that the visuals still work.
+ if (TryComp(component.Owner, out var apcReceiever))
+ apcReceiever.Load = 1;
PowerOff(component);
UpdateAppearance(component);
}
@@ -132,6 +211,11 @@ namespace Content.Server.Singularity.EntitySystems
component.IsOn = true;
if (TryComp(component.Owner, out var powerConsumer))
powerConsumer.DrawRate = component.PowerUseActive;
+ if (TryComp(component.Owner, out var apcReceiever))
+ {
+ apcReceiever.Load = component.PowerUseActive;
+ PowerOn(component);
+ }
// Do not directly PowerOn().
// OnReceivedPowerChanged will get fired due to DrawRate change which will turn it on.
UpdateAppearance(component);
@@ -179,9 +263,6 @@ namespace Content.Server.Singularity.EntitySystems
// and thus not firing
DebugTools.Assert(component.IsPowered);
DebugTools.Assert(component.IsOn);
- DebugTools.Assert(TryComp(component.Owner, out var powerConsumer) &&
- (powerConsumer.DrawRate <= powerConsumer.ReceivedPower ||
- MathHelper.CloseTo(powerConsumer.DrawRate, powerConsumer.ReceivedPower, 0.0001f)));
Fire(component);
diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs
index 77d77747e4..ad6e6ac2e9 100644
--- a/Content.Shared.Database/LogType.cs
+++ b/Content.Shared.Database/LogType.cs
@@ -81,4 +81,5 @@ public enum LogType
Stamina = 76,
EntitySpawn = 77,
AdminMessage = 78,
+ Anomaly = 79
}
diff --git a/Content.Shared/Anomaly/Components/AnomalyComponent.cs b/Content.Shared/Anomaly/Components/AnomalyComponent.cs
new file mode 100644
index 0000000000..d19616323d
--- /dev/null
+++ b/Content.Shared/Anomaly/Components/AnomalyComponent.cs
@@ -0,0 +1,268 @@
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.Anomaly.Components;
+
+///
+/// This is used for tracking the general behavior of anomalies.
+/// This doesn't contain the specific implementations for what
+/// they do, just the generic behaviors associated with them.
+///
+/// Anomalies and their related components were designed here: https://hackmd.io/@ss14-design/r1sQbkJOs
+///
+[RegisterComponent, NetworkedComponent]
+public sealed class AnomalyComponent : Component
+{
+ ///
+ /// How likely an anomaly is to grow more dangerous. Moves both up and down.
+ /// Ranges from 0 to 1.
+ /// Values less than 0.5 indicate stability, whereas values greater
+ /// than 0.5 indicate instability, which causes increases in severity.
+ ///
+ ///
+ /// Note that this doesn't refer to stability as a percentage: This is an arbitrary
+ /// value that only matters in relation to the and
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public float Stability = 0f;
+
+ ///
+ /// How severe the effects of an anomaly are. Moves only upwards.
+ /// Ranges from 0 to 1.
+ /// A value of 0 indicates effects of extrememly minimal severity, whereas greater
+ /// values indicate effects of linearly increasing severity.
+ ///
+ ///
+ /// Wacky-Stability scale lives on in my heart. - emo
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public float Severity = 0f;
+
+ #region Health
+ ///
+ /// The internal "health" of an anomaly.
+ /// Ranges from 0 to 1.
+ /// When the health of an anomaly reaches 0, it is destroyed without ever
+ /// reaching a supercritical point.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public float Health = 1f;
+
+ ///
+ /// If the of the anomaly exceeds this value, it
+ /// becomes too unstable to support itself and starts decreasing in .
+ ///
+ [DataField("decayhreshold"), ViewVariables(VVAccess.ReadWrite)]
+ public float DecayThreshold = 0.15f;
+
+ ///
+ /// The amount of health lost when the stability is below the
+ ///
+ [DataField("healthChangePerSecond"), ViewVariables(VVAccess.ReadWrite)]
+ public float HealthChangePerSecond = -0.05f;
+ #endregion
+
+ #region Growth
+ ///
+ /// If the of the anomaly exceeds this value, it
+ /// becomes unstable and starts increasing in .
+ ///
+ [DataField("growthThreshold"), ViewVariables(VVAccess.ReadWrite)]
+ public float GrowthThreshold = 0.5f;
+
+ ///
+ /// A coefficient used for calculating the increase in severity when above the GrowthThreshold
+ ///
+ [DataField("severityGrowthCoefficient"), ViewVariables(VVAccess.ReadWrite)]
+ public float SeverityGrowthCoefficient = 0.07f;
+ #endregion
+
+ #region Pulse
+ ///
+ /// The time at which the next artifact pulse will occur.
+ ///
+ [DataField("nextPulseTime", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan NextPulseTime = TimeSpan.MaxValue;
+
+ ///
+ /// The minimum interval between pulses.
+ ///
+ [DataField("minPulseLength")]
+ public TimeSpan MinPulseLength = TimeSpan.FromMinutes(1);
+
+ ///
+ /// The maximum interval between pulses.
+ ///
+ [DataField("maxPulseLength")]
+ public TimeSpan MaxPulseLength = TimeSpan.FromMinutes(2);
+
+ ///
+ /// A percentage by which the length of a pulse might vary.
+ ///
+ [DataField("pulseVariation")]
+ public float PulseVariation = .1f;
+
+ ///
+ /// The sound played when an anomaly pulses
+ ///
+ [DataField("pulseSound")]
+ public SoundSpecifier? PulseSound = new SoundCollectionSpecifier("RadiationPulse");
+
+ ///
+ /// The sound plays when an anomaly goes supercritical
+ ///
+ [DataField("supercriticalSound")]
+ public SoundSpecifier? SupercriticalSound = new SoundCollectionSpecifier("explosion");
+ #endregion
+
+ ///
+ /// The range of initial values for stability
+ ///
+ ///
+ /// +/- 0.2 from perfect stability (0.5)
+ ///
+ [DataField("initialStabilityRange")]
+ public (float, float) InitialStabilityRange = (0.4f, 0.6f);
+
+ ///
+ /// The range of initial values for severity
+ ///
+ ///
+ /// Between 0 and 0.5, which should be all mild effects
+ ///
+ [DataField("initialSeverityRange")]
+ public (float, float) InitialSeverityRange = (0.1f, 0.5f);
+
+ ///
+ /// The particle type that increases the severity of the anomaly.
+ ///
+ [DataField("severityParticleType")]
+ public AnomalousParticleType SeverityParticleType;
+
+ ///
+ /// The amount that the increases by when hit
+ /// of an anomalous particle of .
+ ///
+ [DataField("severityPerSeverityHit")]
+ public float SeverityPerSeverityHit = 0.025f;
+
+ ///
+ /// The particle type that destabilizes the anomaly.
+ ///
+ [DataField("destabilizingParticleType")]
+ public AnomalousParticleType DestabilizingParticleType;
+
+ ///
+ /// The amount that the increases by when hit
+ /// of an anomalous particle of .
+ ///
+ [DataField("stabilityPerDestabilizingHit")]
+ public float StabilityPerDestabilizingHit = 0.04f;
+
+ ///
+ /// The particle type that weakens the anomalys health.
+ ///
+ [DataField("weakeningParticleType")]
+ public AnomalousParticleType WeakeningParticleType;
+
+ ///
+ /// The amount that the increases by when hit
+ /// of an anomalous particle of .
+ ///
+ [DataField("healthPerWeakeningeHit")]
+ public float HealthPerWeakeningeHit = -0.05f;
+
+ ///
+ /// The amount that the increases by when hit
+ /// of an anomalous particle of .
+ ///
+ [DataField("stabilityPerWeakeningeHit")]
+ public float StabilityPerWeakeningeHit = -0.02f;
+
+ #region Points and Vessels
+ ///
+ /// The vessel that the anomaly is connceted to. Stored so that multiple
+ /// vessels cannot connect to the same anomaly.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public EntityUid? ConnectedVessel;
+
+ ///
+ /// The minimum amount of research points generated per second
+ ///
+ [DataField("minPointsPerSecond")]
+ public int MinPointsPerSecond;
+
+ ///
+ /// The maximum amount of research points generated per second
+ /// This doesn't include the point bonus for being unstable.
+ ///
+ [DataField("maxPointsPerSecond")]
+ public int MaxPointsPerSecond = 100;
+ #endregion
+}
+
+[Serializable, NetSerializable]
+public sealed class AnomalyComponentState : ComponentState
+{
+ public float Severity;
+ public float Stability;
+ public float Health;
+ public TimeSpan NextPulseTime;
+
+ public AnomalyComponentState(float severity, float stability, float health, TimeSpan nextPulseTime)
+ {
+ Severity = severity;
+ Stability = stability;
+ Health = health;
+ NextPulseTime = nextPulseTime;
+ }
+}
+
+///
+/// Event raised at regular intervals on an anomaly to do whatever its effect is.
+///
+///
+///
+[ByRefEvent]
+public readonly record struct AnomalyPulseEvent(float Stabiltiy, float Severity)
+{
+ public readonly float Stabiltiy = Stabiltiy;
+ public readonly float Severity = Severity;
+}
+
+///
+/// Event raised on an anomaly when it reaches a supercritical point.
+///
+[ByRefEvent]
+public readonly record struct AnomalySupercriticalEvent;
+
+///
+/// Event broadcast after an anomaly goes supercritical
+///
+/// The anomaly being shut down.
+/// Whether or not the anomaly shut down passively or via a supercritical event.
+[ByRefEvent]
+public readonly record struct AnomalyShutdownEvent(EntityUid Anomaly, bool Supercritical);
+
+///
+/// Event broadcast when an anomaly's severity is changed.
+///
+/// The anomaly being changed
+[ByRefEvent]
+public readonly record struct AnomalySeverityChangedEvent(EntityUid Anomaly, float Severity);
+
+///
+/// Event broadcast when an anomaly's stability is changed.
+///
+[ByRefEvent]
+public readonly record struct AnomalyStabilityChangedEvent(EntityUid Anomaly, float Stability);
+
+///
+/// Event broadcast when an anomaly's health is changed.
+///
+/// The anomaly being changed
+[ByRefEvent]
+public readonly record struct AnomalyHealthChangedEvent(EntityUid Anomaly, float Health);
diff --git a/Content.Shared/Anomaly/Components/AnomalyPulsingComponent.cs b/Content.Shared/Anomaly/Components/AnomalyPulsingComponent.cs
new file mode 100644
index 0000000000..9c12ec11c8
--- /dev/null
+++ b/Content.Shared/Anomaly/Components/AnomalyPulsingComponent.cs
@@ -0,0 +1,22 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.Anomaly.Components;
+
+///
+/// This component tracks anomalies that are currently pulsing
+///
+[RegisterComponent]
+public sealed class AnomalyPulsingComponent : Component
+{
+ ///
+ /// The time at which the pulse will be over.
+ ///
+ [DataField("endTime", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan EndTime = TimeSpan.MaxValue;
+
+ ///
+ /// How long the pulse visual lasts
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan PulseDuration = TimeSpan.FromSeconds(5);
+}
diff --git a/Content.Shared/Anomaly/Components/AnomalySupercriticalComponent.cs b/Content.Shared/Anomaly/Components/AnomalySupercriticalComponent.cs
new file mode 100644
index 0000000000..bc5bb381ef
--- /dev/null
+++ b/Content.Shared/Anomaly/Components/AnomalySupercriticalComponent.cs
@@ -0,0 +1,37 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.Anomaly.Components;
+
+///
+/// Tracks anomalies going supercritical
+///
+[RegisterComponent, NetworkedComponent]
+public sealed class AnomalySupercriticalComponent : Component
+{
+ ///
+ /// The time when the supercritical animation ends and it does whatever effect.
+ ///
+ [DataField("endTime", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan EndTime = TimeSpan.MaxValue;
+
+ ///
+ /// The length of the animation before it goes supercritical.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan SupercriticalDuration = TimeSpan.FromSeconds(10);
+
+ ///
+ /// The maximum size the anomaly scales to while going supercritical
+ ///
+ [DataField("maxScaleAmount")]
+ public float MaxScaleAmount = 3;
+}
+
+[Serializable, NetSerializable]
+public sealed class AnomalySupercriticalComponentState : ComponentState
+{
+ public TimeSpan EndTime;
+ public TimeSpan Duration;
+}
diff --git a/Content.Shared/Anomaly/Effects/Components/ElectricityAnomalyComponent.cs b/Content.Shared/Anomaly/Effects/Components/ElectricityAnomalyComponent.cs
new file mode 100644
index 0000000000..fc430127ef
--- /dev/null
+++ b/Content.Shared/Anomaly/Effects/Components/ElectricityAnomalyComponent.cs
@@ -0,0 +1,14 @@
+namespace Content.Shared.Anomaly.Effects.Components;
+
+[RegisterComponent]
+public sealed class ElectricityAnomalyComponent : Component
+{
+ [DataField("maxElectrocutionRange"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaxElectrocuteRange = 6f;
+
+ [DataField("maxElectrocuteDamage"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaxElectrocuteDamage = 20f;
+
+ [DataField("maxElectrocuteDuration"), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan MaxElectrocuteDuration = TimeSpan.FromSeconds(8);
+}
diff --git a/Content.Shared/Anomaly/Effects/Components/GravityAnomalyComponent.cs b/Content.Shared/Anomaly/Effects/Components/GravityAnomalyComponent.cs
new file mode 100644
index 0000000000..a6f80aeda0
--- /dev/null
+++ b/Content.Shared/Anomaly/Effects/Components/GravityAnomalyComponent.cs
@@ -0,0 +1,53 @@
+namespace Content.Shared.Anomaly.Effects.Components;
+
+[RegisterComponent]
+public sealed class GravityAnomalyComponent : Component
+{
+ ///
+ /// The maximumum size the GravityWellComponent MaxRange can be.
+ /// Is scaled linearly with stability.
+ ///
+ [DataField("maxGravityWellRange"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaxGravityWellRange = 8f;
+
+ ///
+ /// The maximum distance from which the anomaly
+ /// can throw you via a pulse.
+ ///
+ [DataField("maxThrowRange"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaxThrowRange = 5f;
+
+ ///
+ /// The maximum strength the anomaly
+ /// can throw you via a pulse
+ ///
+ [DataField("maxThrowStrength"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaxThrowStrength = 10;
+
+ ///
+ /// The maximum Intensity of the RadiationSourceComponent.
+ /// Is scaled linearly with stability.
+ ///
+ [DataField("maxRadiationIntensity"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaxRadiationIntensity = 3f;
+
+ ///
+ /// The minimum acceleration value for GravityWellComponent
+ /// Is scaled linearly with stability.
+ ///
+ [DataField("minAccel"), ViewVariables(VVAccess.ReadWrite)]
+ public float MinAccel = 1f;
+
+ ///
+ /// The maximum acceleration value for GravityWellComponent
+ /// Is scaled linearly with stability.
+ ///
+ [DataField("maxAccel"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaxAccel = 5f;
+
+ ///
+ /// The range around the anomaly that will be spaced on supercritical.
+ ///
+ [DataField("spaceRange"), ViewVariables(VVAccess.ReadWrite)]
+ public float SpaceRange = 3f;
+}
diff --git a/Content.Shared/Anomaly/Effects/Components/PyroclasticAnomalyComponent.cs b/Content.Shared/Anomaly/Effects/Components/PyroclasticAnomalyComponent.cs
new file mode 100644
index 0000000000..5f3c1c2595
--- /dev/null
+++ b/Content.Shared/Anomaly/Effects/Components/PyroclasticAnomalyComponent.cs
@@ -0,0 +1,54 @@
+using Content.Shared.Atmos;
+
+namespace Content.Shared.Anomaly.Effects.Components;
+
+[RegisterComponent]
+public sealed class PyroclasticAnomalyComponent : Component
+{
+ ///
+ /// The MAXIMUM amount of heat released per second.
+ /// This is scaled linearly with the Severity of the anomaly.
+ ///
+ ///
+ /// I have no clue if this is balanced.
+ ///
+ [DataField("heatPerSecond")]
+ public float HeatPerSecond = 50;
+
+ ///
+ /// The maximum distance from which you can be ignited by the anomaly.
+ ///
+ [DataField("maximumIgnitionRadius")]
+ public float MaximumIgnitionRadius = 8f;
+
+ ///
+ /// The minimum amount of severity required
+ /// before the anomaly becomes a hotspot.
+ ///
+ [DataField("anomalyHotspotThreshold")]
+ public float AnomalyHotspotThreshold = 0.6f;
+
+ ///
+ /// The temperature of the hotspot where the anomaly is
+ ///
+ [DataField("hotspotExposeTemperature")]
+ public float HotspotExposeTemperature = 1000;
+
+ ///
+ /// The volume of the hotspot where the anomaly is.
+ ///
+ [DataField("hotspotExposeVolume")]
+ public float HotspotExposeVolume = 50;
+
+ ///
+ /// Gas released when the anomaly goes supercritical.
+ ///
+ [DataField("supercriticalGas")]
+ public Gas SupercriticalGas = Gas.Plasma;
+
+ ///
+ /// The amount of gas released when the anomaly goes supercritical
+ ///
+ [DataField("supercriticalMoleAmount")]
+ public float SupercriticalMoleAmount = 50f;
+}
diff --git a/Content.Shared/Anomaly/Effects/SharedGravityAnomalySystem.cs b/Content.Shared/Anomaly/Effects/SharedGravityAnomalySystem.cs
new file mode 100644
index 0000000000..8cb1714afb
--- /dev/null
+++ b/Content.Shared/Anomaly/Effects/SharedGravityAnomalySystem.cs
@@ -0,0 +1,65 @@
+using System.Linq;
+using Content.Shared.Anomaly.Components;
+using Content.Shared.Anomaly.Effects.Components;
+using Content.Shared.Construction.Components;
+using Content.Shared.Construction.EntitySystems;
+using Content.Shared.Throwing;
+using Robust.Shared.Map;
+
+namespace Content.Shared.Anomaly.Effects;
+
+public abstract class SharedGravityAnomalySystem : EntitySystem
+{
+ [Dependency] private readonly IMapManager _map = default!;
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly SharedAnchorableSystem _anchorable = default!;
+ [Dependency] private readonly ThrowingSystem _throwing = default!;
+ [Dependency] private readonly SharedTransformSystem _xform = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnAnomalyPulse);
+ SubscribeLocalEvent(OnSupercritical);
+ }
+
+ private void OnAnomalyPulse(EntityUid uid, GravityAnomalyComponent component, ref AnomalyPulseEvent args)
+ {
+ var xform = Transform(uid);
+ var range = component.MaxThrowRange * args.Severity;
+ var strength = component.MaxThrowStrength * args.Severity;
+ var lookup = _lookup.GetEntitiesInRange(uid, range, LookupFlags.Dynamic | LookupFlags.Sundries);
+ foreach (var ent in lookup)
+ {
+ var tempXform = Transform(ent);
+
+ var foo = tempXform.MapPosition.Position - xform.MapPosition.Position;
+ _throwing.TryThrow(ent, foo.Normalized * 10, strength, uid, 0);
+ }
+ }
+
+ private void OnSupercritical(EntityUid uid, GravityAnomalyComponent component, ref AnomalySupercriticalEvent args)
+ {
+ var xform = Transform(uid);
+ if (!_map.TryGetGrid(xform.GridUid, out var grid))
+ return;
+
+ var worldPos = _xform.GetWorldPosition(xform);
+ var tileref = grid.GetTilesIntersecting(new Circle(worldPos, component.SpaceRange)).ToArray();
+ var tiles = tileref.Select(t => (t.GridIndices, Tile.Empty)).ToList();
+ grid.SetTiles(tiles);
+
+ var range = component.MaxThrowRange * 2;
+ var strength = component.MaxThrowStrength * 2;
+ var lookup = _lookup.GetEntitiesInRange(uid, range, LookupFlags.Dynamic | LookupFlags.Sundries);
+ foreach (var ent in lookup)
+ {
+ var tempXform = Transform(ent);
+
+ var foo = tempXform.MapPosition.Position - xform.MapPosition.Position;
+ Logger.Debug($"{ToPrettyString(ent)}: {foo}: {foo.Normalized}: {foo.Normalized * 10}");
+ _throwing.TryThrow(ent, foo * 5, strength, uid, 0);
+ }
+ }
+}
+
diff --git a/Content.Shared/Anomaly/SharedAnomaly.cs b/Content.Shared/Anomaly/SharedAnomaly.cs
new file mode 100644
index 0000000000..fe93cff117
--- /dev/null
+++ b/Content.Shared/Anomaly/SharedAnomaly.cs
@@ -0,0 +1,97 @@
+using Robust.Shared.Serialization;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Anomaly;
+
+[Serializable, NetSerializable]
+public enum AnomalyVisuals : byte
+{
+ IsPulsing,
+ Supercritical
+}
+
+[Serializable, NetSerializable]
+public enum AnomalyVisualLayers : byte
+{
+ Base,
+ Animated
+}
+
+///
+/// The types of anomalous particles used
+/// for interfacing with anomalies.
+///
+///
+/// The only thought behind these names is that
+/// they're a continuation of radioactive particles.
+/// Yes i know detla+ waves exist, but they're not
+/// common enough for me to care.
+///
+[Serializable, NetSerializable]
+public enum AnomalousParticleType : byte
+{
+ Delta,
+ Epsilon,
+ Zeta
+}
+
+[Serializable, NetSerializable]
+public enum AnomalyVesselVisuals : byte
+{
+ HasAnomaly
+}
+
+[Serializable, NetSerializable]
+public enum AnomalyVesselVisualLayers : byte
+{
+ Base
+}
+
+[Serializable, NetSerializable]
+public enum AnomalyScannerUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed class AnomalyScannerUserInterfaceState : BoundUserInterfaceState
+{
+ public FormattedMessage Message;
+
+ public TimeSpan? NextPulseTime;
+
+ public AnomalyScannerUserInterfaceState(FormattedMessage message, TimeSpan? nextPulseTime)
+ {
+ Message = message;
+ NextPulseTime = nextPulseTime;
+ }
+}
+
+[Serializable, NetSerializable]
+public enum AnomalyGeneratorUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed class AnomalyGeneratorUserInterfaceState : BoundUserInterfaceState
+{
+ public TimeSpan CooldownEndTime;
+
+ public int FuelAmount;
+
+ public int FuelCost;
+
+ public AnomalyGeneratorUserInterfaceState(TimeSpan cooldownEndTime, int fuelAmount, int fuelCost)
+ {
+ CooldownEndTime = cooldownEndTime;
+ FuelAmount = fuelAmount;
+ FuelCost = fuelCost;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class AnomalyGeneratorGenerateButtonPressedEvent : BoundUserInterfaceMessage
+{
+
+}
diff --git a/Content.Shared/Anomaly/SharedAnomalySystem.cs b/Content.Shared/Anomaly/SharedAnomalySystem.cs
new file mode 100644
index 0000000000..6d954dcc82
--- /dev/null
+++ b/Content.Shared/Anomaly/SharedAnomalySystem.cs
@@ -0,0 +1,319 @@
+using Content.Shared.Administration.Logs;
+using Content.Shared.Anomaly.Components;
+using Content.Shared.Database;
+using Robust.Shared.GameStates;
+using Robust.Shared.Network;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Anomaly;
+
+public abstract class SharedAnomalySystem : EntitySystem
+{
+ [Dependency] protected readonly IGameTiming Timing = default!;
+ [Dependency] private readonly INetManager _net = default!;
+ [Dependency] protected readonly IRobustRandom Random = default!;
+ [Dependency] protected readonly ISharedAdminLogManager Log = default!;
+ [Dependency] protected readonly SharedAudioSystem Audio = default!;
+ [Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnAnomalyGetState);
+ SubscribeLocalEvent(OnAnomalyHandleState);
+ SubscribeLocalEvent(OnSupercriticalGetState);
+ SubscribeLocalEvent(OnSupercriticalHandleState);
+
+ SubscribeLocalEvent(OnAnomalyUnpause);
+ SubscribeLocalEvent(OnPulsingUnpause);
+ SubscribeLocalEvent(OnSupercriticalUnpause);
+ }
+
+ private void OnAnomalyGetState(EntityUid uid, AnomalyComponent component, ref ComponentGetState args)
+ {
+ args.State = new AnomalyComponentState(
+ component.Severity,
+ component.Stability,
+ component.Health,
+ component.NextPulseTime);
+ }
+
+ private void OnAnomalyHandleState(EntityUid uid, AnomalyComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is not AnomalyComponentState state)
+ return;
+ component.Severity = state.Severity;
+ component.Stability = state.Stability;
+ component.Health = state.Health;
+ component.NextPulseTime = state.NextPulseTime;
+ }
+
+ private void OnSupercriticalGetState(EntityUid uid, AnomalySupercriticalComponent component, ref ComponentGetState args)
+ {
+ args.State = new AnomalySupercriticalComponentState
+ {
+ EndTime = component.EndTime,
+ Duration = component.SupercriticalDuration
+ };
+ }
+
+ private void OnSupercriticalHandleState(EntityUid uid, AnomalySupercriticalComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is not AnomalySupercriticalComponentState state)
+ return;
+
+ component.EndTime = state.EndTime;
+ component.SupercriticalDuration = state.Duration;
+ }
+
+ private void OnAnomalyUnpause(EntityUid uid, AnomalyComponent component, ref EntityUnpausedEvent args)
+ {
+ component.NextPulseTime += args.PausedTime;
+ Dirty(component);
+ }
+
+ private void OnPulsingUnpause(EntityUid uid, AnomalyPulsingComponent component, ref EntityUnpausedEvent args)
+ {
+ component.EndTime += args.PausedTime;
+ }
+
+ private void OnSupercriticalUnpause(EntityUid uid, AnomalySupercriticalComponent component, ref EntityUnpausedEvent args)
+ {
+ component.EndTime += args.PausedTime;
+ Dirty(component);
+ }
+
+ public void DoAnomalyPulse(EntityUid uid, AnomalyComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var variation = Random.NextFloat(-component.PulseVariation, component.PulseVariation) + 1;
+ component.NextPulseTime = Timing.CurTime + GetPulseLength(component) * variation;
+
+ // if we are above the growth threshold, then grow before the pulse
+ if (component.Stability > component.GrowthThreshold)
+ {
+ ChangeAnomalySeverity(uid, GetSeverityIncreaseFromGrowth(component), component);
+ }
+ else
+ {
+ // just doing this to update the scanner ui
+ // as they hook into these events
+ ChangeAnomalySeverity(uid, 0);
+ }
+
+ Log.Add(LogType.Anomaly, LogImpact.Medium, $"Anomaly {ToPrettyString(uid)} pulsed with severity {component.Severity}.");
+ Audio.PlayPvs(component.PulseSound, uid);
+
+ var pulse = EnsureComp(uid);
+ pulse.EndTime = Timing.CurTime + pulse.PulseDuration;
+ Appearance.SetData(uid, AnomalyVisuals.IsPulsing, true);
+
+ var ev = new AnomalyPulseEvent(component.Stability, component.Severity);
+ RaiseLocalEvent(uid, ref ev);
+ }
+
+ ///
+ /// Begins the animation for going supercritical
+ ///
+ ///
+ public void StartSupercriticalEvent(EntityUid uid)
+ {
+ // don't restart it if it's already begun
+ if (HasComp(uid))
+ return;
+
+ Log.Add(LogType.Anomaly, LogImpact.High, $"Anomaly {ToPrettyString(uid)} began to go supercritical.");
+
+ var super = EnsureComp(uid);
+ super.EndTime = Timing.CurTime + super.SupercriticalDuration;
+ Appearance.SetData(uid, AnomalyVisuals.Supercritical, true);
+ Dirty(super);
+ }
+
+ ///
+ /// Does the supercritical event for the anomaly.
+ /// This isn't called once the anomaly reaches the point, but
+ /// after the animation for it going supercritical
+ ///
+ ///
+ ///
+ public void DoAnomalySupercriticalEvent(EntityUid uid, AnomalyComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+ Audio.PlayPvs(component.SupercriticalSound, uid);
+
+ var ev = new AnomalySupercriticalEvent();
+ RaiseLocalEvent(uid, ref ev);
+
+ EndAnomaly(uid, component, true);
+ }
+
+ ///
+ /// Ends an anomaly, cleaning up all entities that may be associated with it.
+ ///
+ /// The anomaly being shut down
+ ///
+ /// Whether or not the anomaly ended via supercritical event
+ public void EndAnomaly(EntityUid uid, AnomalyComponent? component = null, bool supercritical = false)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var ev = new AnomalyShutdownEvent(uid, supercritical);
+ RaiseLocalEvent(uid, ref ev, true);
+
+ Log.Add(LogType.Anomaly, LogImpact.Extreme, $"Anomaly {ToPrettyString(uid)} went supercritical.");
+
+ if (Terminating(uid) || _net.IsClient)
+ return;
+ Del(uid);
+ }
+
+ ///
+ /// Changes the stability of the anomaly.
+ ///
+ ///
+ ///
+ ///
+ public void ChangeAnomalyStability(EntityUid uid, float change, AnomalyComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var newVal = component.Stability + change;
+
+ component.Stability = Math.Clamp(newVal, 0, 1);
+ Dirty(component);
+
+ var ev = new AnomalyStabilityChangedEvent(uid, component.Stability);
+ RaiseLocalEvent(uid, ref ev, true);
+ }
+
+ ///
+ /// Changes the severity of an anomaly, going supercritical if it exceeds 1.
+ ///
+ ///
+ ///
+ ///
+ public void ChangeAnomalySeverity(EntityUid uid, float change, AnomalyComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var newVal = component.Severity + change;
+
+ if (newVal >= 1)
+ StartSupercriticalEvent(uid);
+
+ component.Severity = Math.Clamp(newVal, 0, 1);
+ Dirty(component);
+
+ var ev = new AnomalySeverityChangedEvent(uid, component.Severity);
+ RaiseLocalEvent(uid, ref ev, true);
+ }
+
+ ///
+ /// Changes the health of an anomaly, ending it if it's less than 0.
+ ///
+ ///
+ ///
+ ///
+ public void ChangeAnomalyHealth(EntityUid uid, float change, AnomalyComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var newVal = component.Health + change;
+
+ if (newVal < 0)
+ {
+ EndAnomaly(uid, component);
+ return;
+ }
+
+ component.Health = Math.Clamp(newVal, 0, 1);
+ Dirty(component);
+
+ var ev = new AnomalyHealthChangedEvent(uid, component.Health);
+ RaiseLocalEvent(uid, ref ev, true);
+ }
+
+ ///
+ /// Gets the length of time between each pulse
+ /// for an anomaly based on its current stability.
+ ///
+ ///
+ /// For anomalies under the instability theshold, this will return the maximum length.
+ /// For those over the theshold, they will return an amount between the maximum and
+ /// minium value based on a linear relationship with the stability.
+ ///
+ ///
+ /// The length of time as a TimeSpan, not including random variation.
+ public TimeSpan GetPulseLength(AnomalyComponent component)
+ {
+ DebugTools.Assert(component.MaxPulseLength > component.MinPulseLength);
+ var modifier = Math.Clamp((component.Stability - component.GrowthThreshold) / component.GrowthThreshold, 0, 1);
+ return (component.MaxPulseLength - component.MinPulseLength) * modifier + component.MinPulseLength;
+ }
+
+ ///
+ /// Gets the increase in an anomaly's severity due
+ /// to being above its growth threshold
+ ///
+ ///
+ /// The increase in severity for this anomaly
+ private float GetSeverityIncreaseFromGrowth(AnomalyComponent component)
+ {
+ var score = 1 + Math.Max(component.Stability - component.GrowthThreshold, 0) * 10;
+ return score * component.SeverityGrowthCoefficient;
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ foreach (var anomaly in EntityQuery())
+ {
+ var ent = anomaly.Owner;
+
+ // if the stability is under the death threshold,
+ // update it every second to start killing it slowly.
+ if (anomaly.Stability < anomaly.DecayThreshold)
+ {
+ ChangeAnomalyHealth(ent, anomaly.HealthChangePerSecond * frameTime, anomaly);
+ }
+
+ if (Timing.CurTime > anomaly.NextPulseTime)
+ {
+ DoAnomalyPulse(ent, anomaly);
+ }
+ }
+
+ foreach (var pulse in EntityQuery())
+ {
+ var ent = pulse.Owner;
+
+ if (Timing.CurTime > pulse.EndTime)
+ {
+ Appearance.SetData(ent, AnomalyVisuals.IsPulsing, false);
+ RemComp(ent, pulse);
+ }
+ }
+
+ foreach (var (super, anom) in EntityQuery())
+ {
+ var ent = anom.Owner;
+
+ if (Timing.CurTime <= super.EndTime)
+ continue;
+ DoAnomalySupercriticalEvent(ent, anom);
+ RemComp(ent, super);
+ }
+ }
+}
diff --git a/Content.Shared/Singularity/Components/SharedEmitterComponent.cs b/Content.Shared/Singularity/Components/SharedEmitterComponent.cs
index f2362cae85..7579823246 100644
--- a/Content.Shared/Singularity/Components/SharedEmitterComponent.cs
+++ b/Content.Shared/Singularity/Components/SharedEmitterComponent.cs
@@ -1,8 +1,10 @@
using System.Threading;
using Content.Shared.Construction.Prototypes;
using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
namespace Content.Shared.Singularity.Components;
@@ -25,9 +27,12 @@ public sealed class EmitterComponent : Component
///
/// The entity that is spawned when the emitter fires.
///
- [DataField("boltType")]
+ [DataField("boltType", customTypeSerializer: typeof(PrototypeIdSerializer))]
public string BoltType = "EmitterBolt";
+ [DataField("selectableTypes", customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List SelectableTypes = new();
+
///
/// The current amount of power being used.
///
diff --git a/Content.Shared/Verbs/VerbCategory.cs b/Content.Shared/Verbs/VerbCategory.cs
index 1f63f3d106..63a1cbced6 100644
--- a/Content.Shared/Verbs/VerbCategory.cs
+++ b/Content.Shared/Verbs/VerbCategory.cs
@@ -81,5 +81,7 @@ namespace Content.Shared.Verbs
public static readonly VerbCategory SetSensor = new("verb-categories-set-sensor", null);
public static readonly VerbCategory Lever = new("verb-categories-lever", null);
+
+ public static readonly VerbCategory SelectType = new("verb-categories-select-type", null);
}
}
diff --git a/Resources/Audio/Ambience/Objects/anomaly_generator.ogg b/Resources/Audio/Ambience/Objects/anomaly_generator.ogg
new file mode 100644
index 0000000000..7b28e732a5
Binary files /dev/null and b/Resources/Audio/Ambience/Objects/anomaly_generator.ogg differ
diff --git a/Resources/Audio/Ambience/Objects/attributions.yml b/Resources/Audio/Ambience/Objects/attributions.yml
new file mode 100644
index 0000000000..8322c91a48
--- /dev/null
+++ b/Resources/Audio/Ambience/Objects/attributions.yml
@@ -0,0 +1,4 @@
+- files: ["anomaly_generator.ogg"]
+ license: "CC0-1.0"
+ copyright: "Created by steaq, converted Mono and .ogg by EmoGarbage"
+ source: "https://freesound.org/people/steaq/sounds/509249/"
\ No newline at end of file
diff --git a/Resources/Audio/Ambience/anomaly_drone.ogg b/Resources/Audio/Ambience/anomaly_drone.ogg
new file mode 100644
index 0000000000..5e9d4f8247
Binary files /dev/null and b/Resources/Audio/Ambience/anomaly_drone.ogg differ
diff --git a/Resources/Audio/Ambience/attributions.yml b/Resources/Audio/Ambience/attributions.yml
new file mode 100644
index 0000000000..e6979603be
--- /dev/null
+++ b/Resources/Audio/Ambience/attributions.yml
@@ -0,0 +1,4 @@
+- files: ["anomaly_drone.ogg"]
+ license: "CC0-1.0"
+ copyright: "Created by Joao_Janz, edited and converted to Mono by EmoGarbage"
+ source: "https://freesound.org/people/Joao_Janz/sounds/478472/"
\ No newline at end of file
diff --git a/Resources/Audio/Items/attributions.yml b/Resources/Audio/Items/attributions.yml
index 92bce1cd2d..aee30635ab 100644
--- a/Resources/Audio/Items/attributions.yml
+++ b/Resources/Audio/Items/attributions.yml
@@ -1,3 +1,8 @@
+- files: ["beep.ogg"]
+ license: "CC0-1.0"
+ copyright: "Created by Pól, converted to OGG and Mono by EmoGarbage"
+ source: "https://freesound.org/people/P%C3%B3l/sounds/385927/"
+
- files: ["trayhit1.ogg"]
license: "CC-BY-SA-3.0"
copyright: "Time immemorial"
diff --git a/Resources/Audio/Items/beep.ogg b/Resources/Audio/Items/beep.ogg
new file mode 100644
index 0000000000..65abe4208c
Binary files /dev/null and b/Resources/Audio/Items/beep.ogg differ
diff --git a/Resources/Locale/en-US/anomaly/anomaly.ftl b/Resources/Locale/en-US/anomaly/anomaly.ftl
new file mode 100644
index 0000000000..725742ece6
--- /dev/null
+++ b/Resources/Locale/en-US/anomaly/anomaly.ftl
@@ -0,0 +1,30 @@
+anomaly-vessel-component-anomaly-assigned = Anomaly assigned to vessel.
+anomaly-vessel-component-not-assigned = This vessel is not assigned to any anomaly. Try using a scanner on it.
+anomaly-vessel-component-assigned = This vessel is currently assigned to an anomaly.
+
+anomaly-particles-delta = Delta particles
+anomaly-particles-epsilon = Epsilon particles
+anomaly-particles-zeta = Zeta particles
+
+anomaly-scanner-component-scan-complete = Scan complete!
+
+anomaly-scanner-ui-title = anomaly scanner
+anomaly-scanner-no-anomaly = No anomaly currently scanned.
+anomaly-scanner-severity-percentage = Current severity: [color=gray]{$percent}[/color]
+anomaly-scanner-stability-low = Current anomaly state: [color=gold]Decaying[/color]
+anomaly-scanner-stability-medium = Current anomaly state: [color=forestgreen]Stable[/color]
+anomaly-scanner-stability-high = Current anomaly state: [color=crimson]Growing[/color]
+anomaly-scanner-point-output = Approximate point output: [color=gray]{$point}[/color]
+anomaly-scanner-particle-readout = Particle Reaction Analysis:
+anomaly-scanner-particle-danger = - [color=crimson]Danger type:[/color] {$type}
+anomaly-scanner-particle-unstable = - [color=plum]Unstable type:[/color] {$type}
+anomaly-scanner-particle-containment = - [color=goldenrod]Containment type:[/color] {$type}
+anomaly-scanner-pulse-timer = Time until next pulse: [color=gray]{$time}[/color]
+
+anomaly-generator-ui-title = anomaly generator
+anomaly-generator-fuel-display = Fuel:
+anomaly-generator-cooldown = Cooldown: [color=gray]{$time}[/color]
+anomaly-generator-no-cooldown = Cooldown: [color=gray]Complete[/color]
+anomaly-generator-yes-fire = Status: [color=forestgreen]Ready[/color]
+anomaly-generator-no-fire = Status: [color=crimson]Not ready[/color]
+anomaly-generator-generate = Generate Anomaly
\ No newline at end of file
diff --git a/Resources/Locale/en-US/prototypes/catalog/research/technologies.ftl b/Resources/Locale/en-US/prototypes/catalog/research/technologies.ftl
index d38c715637..5a2ec20e00 100644
--- a/Resources/Locale/en-US/prototypes/catalog/research/technologies.ftl
+++ b/Resources/Locale/en-US/prototypes/catalog/research/technologies.ftl
@@ -73,6 +73,9 @@ technologies-super-powercell-printing-description = Print super powercells.
technologies-scientific-technology = Scientific technology
technologies-scientific-technology-description = The basics of a research team's supplies.
+technologies-anomaly-technology = Anomaly technology
+technologies-anomaly-technology-description = Machines for advanced anomaly containment.
+
technologies-robotics-technology = Robotics technology
technologies-robotics-technology-description = Parts needed for constructing mechanized friends.
diff --git a/Resources/Locale/en-US/singularity/components/emitter-component.ftl b/Resources/Locale/en-US/singularity/components/emitter-component.ftl
index 2c316e1d6b..7c1b33d96b 100644
--- a/Resources/Locale/en-US/singularity/components/emitter-component.ftl
+++ b/Resources/Locale/en-US/singularity/components/emitter-component.ftl
@@ -13,3 +13,6 @@ comp-emitter-not-anchored = The {$target} isn't anchored to the ground!
# Upgrades
emitter-component-upgrade-fire-rate = fire rate
+
+emitter-component-current-type = The current selected type is: {$type}.
+emitter-component-type-set = Type set to: {$type}
\ No newline at end of file
diff --git a/Resources/Locale/en-US/verbs/verb-system.ftl b/Resources/Locale/en-US/verbs/verb-system.ftl
index 32a3f5d521..2bebddca61 100644
--- a/Resources/Locale/en-US/verbs/verb-system.ftl
+++ b/Resources/Locale/en-US/verbs/verb-system.ftl
@@ -26,6 +26,7 @@ verb-categories-channel-select = Channels
verb-categories-set-sensor = Sensor
verb-categories-timer = Set Delay
verb-categories-lever = Lever
+verb-categories-select-type = Select Type
verb-categories-fax = Set Destination
verb-common-toggle-light = Toggle light
diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/science.yml b/Resources/Prototypes/Catalog/Fills/Lockers/science.yml
index 51c4a69901..518c2d6e76 100644
--- a/Resources/Prototypes/Catalog/Fills/Lockers/science.yml
+++ b/Resources/Prototypes/Catalog/Fills/Lockers/science.yml
@@ -9,3 +9,9 @@
- id: ClothingHeadsetScience
- id: ClothingMaskSterile
- id: ClothingOuterCoatLab
+ - id: AnomalyScanner
+ prob: 0.5
+ orGroup: Scanner
+ - id: NodeScanner
+ prob: 0.5
+ orGroup: Scanner
diff --git a/Resources/Prototypes/Catalog/Research/technologies.yml b/Resources/Prototypes/Catalog/Research/technologies.yml
index 32d04c0619..0f6c553661 100644
--- a/Resources/Prototypes/Catalog/Research/technologies.yml
+++ b/Resources/Prototypes/Catalog/Research/technologies.yml
@@ -518,6 +518,21 @@
- MicroManipulatorStockPart
- ScanningModuleStockPart
- NodeScanner
+ - AnomalyScanner
+
+- type: technology
+ name: technologies-anomaly-technology
+ id: AnomalyTechnology
+ description: technologies-anomaly-technology-description
+ icon:
+ sprite: Structures/Machines/Anomaly/ape.rsi
+ state: base
+ requiredPoints: 10000
+ requiredTechnologies:
+ - ScientificTechnology
+ unlockedRecipes:
+ - AnomalyVesselCircuitboard
+ - APECircuitboard
- type: technology
name: technologies-robotics-technology
diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Random/anomaly.yml b/Resources/Prototypes/Entities/Markers/Spawners/Random/anomaly.yml
new file mode 100644
index 0000000000..a082fae7d6
--- /dev/null
+++ b/Resources/Prototypes/Entities/Markers/Spawners/Random/anomaly.yml
@@ -0,0 +1,16 @@
+- type: entity
+ id: RandomAnomalySpawner
+ name: random anomaly spawner
+ parent: MarkerBase
+ components:
+ - type: Sprite
+ layers:
+ - state: red
+ - sprite: Structures/Specific/anomaly.rsi
+ state: anom1
+ - type: RandomSpawner
+ prototypes:
+ - AnomalyPyroclastic
+ - AnomalyGravity
+ - AnomalyElectricity
+ chance: 1
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
index 9810e1a322..8e8a0800c1 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
@@ -179,6 +179,38 @@
Steel: 5
Cable: 1
+- type: entity
+ parent: BaseMachineCircuitboard
+ id: AnomalyVesselCircuitboard
+ name: anomaly vessel machine board
+ description: A machine printed circuit board for an anomaly vessel
+ components:
+ - type: Sprite
+ state: science
+ - type: MachineBoard
+ prototype: MachineAnomalyVessel
+ requirements:
+ ScanningModule: 5
+ materialRequirements:
+ Cable: 1
+ PlasmaGlass: 10
+
+- type: entity
+ parent: BaseMachineCircuitboard
+ id: APECircuitboard
+ name: A.P.E. machine board
+ description: A machine printed circuit board for an A.P.E.
+ components:
+ - type: Sprite
+ state: science
+ - type: MachineBoard
+ prototype: MachineAPE
+ requirements:
+ Capacitor: 1
+ Laser: 3
+ materialRequirements:
+ Cable: 1
+
- type: entity
id: ThermomachineFreezerMachineCircuitBoard
parent: BaseMachineCircuitboard
diff --git a/Resources/Prototypes/Entities/Objects/Specific/Research/anomaly.yml b/Resources/Prototypes/Entities/Objects/Specific/Research/anomaly.yml
new file mode 100644
index 0000000000..e0d862a705
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Specific/Research/anomaly.yml
@@ -0,0 +1,19 @@
+- type: entity
+ parent: BaseItem
+ id: AnomalyScanner
+ name: anomaly scanner
+ description: A hand-held scanner built to collect information on various anomalous objects.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Research/anomalyscanner.rsi
+ netsync: false
+ state: icon
+ - type: ActivatableUI
+ key: enum.AnomalyScannerUiKey.Key
+ closeOnHandDeselect: false
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ - key: enum.AnomalyScannerUiKey.Key
+ type: AnomalyScannerBoundUserInterface
+ - type: AnomalyScanner
diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/projectiles.yml b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/projectiles.yml
index 8101d4246c..0c3ed4708d 100644
--- a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/projectiles.yml
+++ b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/projectiles.yml
@@ -269,6 +269,58 @@
- type: TimedDespawn
lifetime: 0.4
+- type: entity
+ parent: BaseBullet
+ id: AnomalousParticleDelta
+ name: delta particles
+ noSpawn: true
+ components:
+ - type: AnomalousParticle
+ particleType: Delta
+ - type: Sprite
+ sprite: Objects/Weapons/Guns/Projectiles/magic.rsi
+ layers:
+ - state: magicm
+ shader: unshaded
+ - type: Ammo
+ muzzleFlash: null
+ - type: Physics
+ - type: Fixtures
+ fixtures:
+ - shape:
+ !type:PhysShapeAabb
+ bounds: "-0.2,-0.2,0.2,0.2"
+ hard: false
+ id: projectile
+ mask:
+ - Impassable
+ - Opaque
+ - *flybyfixture
+ - type: Projectile
+ damage:
+ types:
+ Heat: 3
+ - type: TimedDespawn
+ lifetime: 3
+
+- type: entity
+ parent: AnomalousParticleDelta
+ id: AnomalousParticleEpsilon
+ name: epsilon particles
+ noSpawn: true
+ components:
+ - type: AnomalousParticle
+ particleType: Epsilon
+
+- type: entity
+ parent: AnomalousParticleDelta
+ id: AnomalousParticleZeta
+ name: zeta particles
+ noSpawn: true
+ components:
+ - type: AnomalousParticle
+ particleType: Zeta
+
# Launcher projectiles (grenade / rocket)
- type: entity
id: BulletRocket
diff --git a/Resources/Prototypes/Entities/Structures/Machines/anomaly_equipment.yml b/Resources/Prototypes/Entities/Structures/Machines/anomaly_equipment.yml
new file mode 100644
index 0000000000..cb108d0b73
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/Machines/anomaly_equipment.yml
@@ -0,0 +1,240 @@
+- type: entity
+ id: MachineAnomalyVessel
+ parent: [ BaseMachinePowered, ConstructibleMachine ]
+ name: anomaly vessel
+ description: A container able to harness a scan of an anomaly and turn it into research points.
+ components:
+ - type: Sprite
+ noRot: true
+ sprite: Structures/Machines/Anomaly/anomaly_vessel.rsi
+ layers:
+ - state: base
+ - state: powered
+ shader: unshaded
+ map: ["enum.PowerDeviceVisualLayers.Powered"]
+ - state: anomaly
+ shader: unshaded
+ map: ["enum.AnomalyVesselVisualLayers.Base"]
+ - state: panel
+ map: ["enum.WiresVisualLayers.MaintenancePanel"]
+ - type: Transform
+ noRot: false
+ - type: AnomalyVessel
+ - type: ResearchClient
+ - type: ActivatableUI
+ key: enum.ResearchClientUiKey.Key
+ - type: ActivatableUIRequiresPower
+ - type: UserInterface
+ interfaces:
+ - key: enum.ResearchClientUiKey.Key
+ type: ResearchClientBoundUserInterface
+ - type: Machine
+ board: AnomalyVesselCircuitboard
+ - type: PointLight
+ radius: 1.2
+ energy: 2
+ color: "#fca3c0"
+ - type: Appearance
+ - type: Wires
+ BoardName: "Vessel"
+ LayoutId: Vessel
+ - type: AmbientSound
+ enabled: false
+ range: 3
+ volume: -8
+ sound:
+ path: /Audio/Ambience/anomaly_drone.ogg
+ - type: GenericVisualizer
+ visuals:
+ enum.PowerDeviceVisuals.Powered:
+ enum.PowerDeviceVisualLayers.Powered:
+ True: { visible: true }
+ False: { visible: false }
+ enum.AnomalyVesselVisuals.HasAnomaly:
+ enum.AnomalyVesselVisualLayers.Base:
+ True: { visible: true }
+ False: { visible: false }
+ enum.WiresVisuals.MaintenancePanelState:
+ enum.WiresVisualLayers.MaintenancePanel:
+ True: { visible: false }
+ False: { visible: true }
+ - type: Explosive
+ explosionType: Default
+ maxIntensity: 20
+ intensitySlope: 30
+ totalIntensity: 30
+ canCreateVacuum: false
+ - type: Destructible
+ thresholds:
+ - trigger:
+ !type:DamageTrigger
+ damage: 150
+ behaviors:
+ - !type:DoActsBehavior
+ acts: ["Destruction"]
+ - !type:PlaySoundBehavior
+ sound:
+ path: /Audio/Effects/metalbreak.ogg
+ - !type:ExplodeBehavior
+
+- type: entity
+ id: MachineAPE
+ parent: [ BaseMachinePowered, ConstructibleMachine ]
+ name: A.P.E.
+ description: An Anomalous Particle Emitter, capable of shooting out unstable particles which can interface with anomalies.
+ components:
+ - type: Sprite
+ noRot: true
+ sprite: Structures/Machines/Anomaly/ape.rsi
+ layers:
+ - state: base
+ - state: unshaded
+ shader: unshaded
+ map: ["enum.PowerDeviceVisualLayers.Powered"]
+ - state: panel
+ map: ["enum.WiresVisualLayers.MaintenancePanel"]
+ - state: firing
+ shader: unshaded
+ visible: false
+ map: ["enum.EmitterVisualLayers.Lights"]
+ - state: locked
+ shader: unshaded
+ visible: false
+ map: ["enum.StorageVisualLayers.Lock"]
+ - type: Transform
+ noRot: false
+ - type: Fixtures
+ fixtures:
+ - shape:
+ !type:PhysShapeCircle
+ radius: 0.35
+ density: 190
+ mask:
+ - MachineMask
+ layer:
+ - MachineLayer
+ - type: Rotatable
+ rotateWhileAnchored: true
+ - type: Machine
+ board: APECircuitboard
+ - type: Lock
+ locked: false
+ - type: AccessReader
+ access: [[ "Research" ]]
+ - type: Emitter
+ onState: firing
+ powerUseActive: 100
+ boltType: AnomalousParticleDelta
+ underpoweredState: underpowered
+ selectableTypes:
+ - AnomalousParticleDelta
+ - AnomalousParticleEpsilon
+ - AnomalousParticleZeta
+ fireBurstSize: 1
+ baseFireBurstDelayMin: 2
+ baseFireBurstDelayMax: 6
+ - type: ApcPowerReceiver
+ powerLoad: 100
+ - type: Gun
+ fireRate: 10 #just has to be fast enough to keep up with upgrades
+ showExamineText: false
+ selectedMode: SemiAuto
+ availableModes:
+ - SemiAuto
+ soundGunshot:
+ path: /Audio/Weapons/Guns/Gunshots/taser2.ogg
+ - type: Appearance
+ - type: WiresVisuals
+ - type: Wires
+ BoardName: "Ape"
+ LayoutId: Ape
+ - type: GenericVisualizer
+ visuals:
+ enum.PowerDeviceVisuals.Powered:
+ enum.PowerDeviceVisualLayers.Powered:
+ True: { visible: true }
+ False: { visible: false }
+
+- type: entity
+ id: MachineAnomalyGenerator
+ parent: BaseMachinePowered
+ name: anomaly generator
+ description: The peak of psuedoscientific technology.
+ placement:
+ mode: AlignTileAny
+ components:
+ - type: Sprite
+ netsync: false
+ sprite: Structures/Machines/Anomaly/anomaly_generator.rsi
+ snapCardinals: true
+ layers:
+ - state: base
+ - state: panel
+ map: ["enum.WiresVisualLayers.MaintenancePanel"]
+ visible: false
+ - state: unshaded
+ shader: unshaded
+ map: ["enum.PowerDeviceVisualLayers.Powered"]
+ - state: inserting
+ visible: false
+ map: ["enum.MaterialStorageVisualLayers.Inserting"]
+ - type: Transform
+ anchored: true
+ - type: ApcPowerReceiver
+ powerLoad: 1500
+ - type: ExtensionCableReceiver
+ - type: AmbientSound
+ range: 5
+ volume: -3
+ sound:
+ path: /Audio/Ambience/Objects/anomaly_generator.ogg
+ - type: Physics
+ bodyType: Static
+ - type: AnomalyGenerator
+ - type: MaterialStorage
+ whitelist:
+ tags:
+ - Sheet
+ materialWhiteList:
+ - Plasma
+ - type: Fixtures
+ fixtures:
+ - shape:
+ !type:PhysShapeAabb
+ bounds: "-1.3,-1.3,1.3,1.3"
+ density: 50
+ mask:
+ - LargeMobMask
+ layer:
+ - WallLayer
+ - type: Repairable
+ fuelCost: 10
+ doAfterDelay: 5
+ - type: Wires
+ BoardName: "AnomalyGenerator"
+ LayoutId: AnomalyGenerator
+ - type: Destructible
+ thresholds:
+ - trigger:
+ !type:DamageTrigger
+ damage: 500
+ behaviors:
+ - !type:DoActsBehavior
+ acts: ["Breakage"]
+ - type: ActivatableUI
+ key: enum.AnomalyGeneratorUiKey.Key
+ - type: ActivatableUIRequiresPower
+ - type: UserInterface
+ interfaces:
+ - key: enum.AnomalyGeneratorUiKey.Key
+ type: AnomalyGeneratorBoundUserInterface
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.PowerDeviceVisuals.Powered:
+ enum.PowerDeviceVisualLayers.Powered:
+ True: { visible: true }
+ False: { visible: false }
+ - type: WiresVisuals
+ - type: StaticPrice
+ price: 5000
\ No newline at end of file
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index 96162ce15e..ae0374c6c8 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -169,6 +169,7 @@
- MiningDrill
- ConveyorBeltAssembly
- AppraisalTool
+ - AnomalyScanner
- RCD
- RCDAmmo
- HydroponicsToolScythe
@@ -314,6 +315,8 @@
- SeedExtractorMachineCircuitboard
- AnalysisComputerCircuitboard
- ExosuitFabricatorMachineCircuitboard
+ - AnomalyVesselCircuitboard
+ - APECircuitboard
- ArtifactAnalyzerMachineCircuitboard
- TraversalDistorterMachineCircuitboard
- BoozeDispenserMachineCircuitboard
diff --git a/Resources/Prototypes/Entities/Structures/Specific/anomalies.yml b/Resources/Prototypes/Entities/Structures/Specific/anomalies.yml
new file mode 100644
index 0000000000..01dd3dce8b
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/Specific/anomalies.yml
@@ -0,0 +1,104 @@
+- type: entity
+ abstract: true
+ id: BaseAnomaly
+ name: anomaly
+ description: A impossible object in space. Should you be standing this close to it?
+ components:
+ - type: Anomaly
+ pulseSound:
+ collection: RadiationPulse
+ params:
+ volume: 5
+ - type: AmbientSound
+ range: 5
+ volume: -5
+ sound:
+ path: /Audio/Ambience/anomaly_drone.ogg
+ - type: Transform
+ anchored: true
+ - type: Physics
+ bodyType: Static
+ - type: Fixtures
+ fixtures:
+ - shape:
+ !type:PhysShapeCircle
+ radius: 0.35
+ density: 50
+ mask:
+ - MobMask
+ layer:
+ - MobLayer
+ - type: Sprite
+ netsync: false
+ drawdepth: Items
+ sprite: Structures/Specific/anomaly.rsi
+ - type: InteractionOutline
+ - type: Clickable
+ - type: Damageable
+ damageContainer: Inorganic
+ damageModifierSet: Metallic
+ - type: Appearance
+ - type: EmitSoundOnSpawn
+ sound:
+ path: /Audio/Effects/teleport_arrival.ogg
+
+- type: entity
+ id: AnomalyPyroclastic
+ parent: BaseAnomaly
+ suffix: Pyroclastic
+ components:
+ - type: Sprite
+ layers:
+ - state: anom1
+ map: ["enum.AnomalyVisualLayers.Base"]
+ - state: anom1-pulse
+ map: ["enum.AnomalyVisualLayers.Animated"]
+ visible: false
+ - type: PointLight
+ radius: 2.0
+ energy: 7.5
+ color: "#fca3c0"
+ castShadows: false
+ - type: PyroclasticAnomaly
+
+- type: entity
+ id: AnomalyGravity
+ parent: BaseAnomaly
+ suffix: Gravity
+ components:
+ - type: Sprite
+ drawdepth: Effects #it needs to draw over stuff.
+ layers:
+ - state: anom2
+ map: ["enum.AnomalyVisualLayers.Base"]
+ - state: anom2-pulse
+ map: ["enum.AnomalyVisualLayers.Animated"]
+ visible: false
+ - type: PointLight
+ radius: 5.0
+ energy: 20
+ color: "#1e070e"
+ castShadows: false
+ - type: GravityAnomaly
+ - type: GravityWell
+ - type: RadiationSource
+
+- type: entity
+ id: AnomalyElectricity
+ parent: BaseAnomaly
+ suffix: Electricity
+ components:
+ - type: Sprite
+ layers:
+ - state: anom3
+ map: ["enum.AnomalyVisualLayers.Base"]
+ - state: anom3-pulse
+ map: ["enum.AnomalyVisualLayers.Animated"]
+ visible: false
+ - type: PointLight
+ radius: 2.0
+ energy: 5.0
+ color: "#ffffaa"
+ castShadows: false
+ - type: ElectricityAnomaly
+ - type: Electrified
\ No newline at end of file
diff --git a/Resources/Prototypes/Entities/Structures/Wallmounts/Signs/signs.yml b/Resources/Prototypes/Entities/Structures/Wallmounts/Signs/signs.yml
index 149b6d56ef..5fa1afa26b 100644
--- a/Resources/Prototypes/Entities/Structures/Wallmounts/Signs/signs.yml
+++ b/Resources/Prototypes/Entities/Structures/Wallmounts/Signs/signs.yml
@@ -246,12 +246,21 @@
- type: entity
parent: BaseSign
id: SignAnomaly
- name: xeno-archeology lab sign
- description: A sign indicating the xeno-archeology lab.
+ name: xenoarcheology lab sign
+ description: A sign indicating the xenoarcheology lab.
components:
- type: Sprite
state: anomaly
+- type: entity
+ parent: BaseSign
+ id: SignAnomaly2
+ name: anomaly lab sign
+ description: A sign indicating the anomalous research lab.
+ components:
+ - type: Sprite
+ state: anomaly2
+
- type: entity
parent: BaseSign
id: SignAtmos
diff --git a/Resources/Prototypes/Recipes/Lathes/devices.yml b/Resources/Prototypes/Recipes/Lathes/devices.yml
index bd4f1146f8..135d1005ae 100644
--- a/Resources/Prototypes/Recipes/Lathes/devices.yml
+++ b/Resources/Prototypes/Recipes/Lathes/devices.yml
@@ -48,3 +48,11 @@
Steel: 100
Plastic: 200
Glass: 100
+
+- type: latheRecipe
+ id: AnomalyScanner
+ result: AnomalyScanner
+ completetime: 2
+ materials:
+ Plastic: 200
+ Glass: 150
\ No newline at end of file
diff --git a/Resources/Prototypes/Recipes/Lathes/electronics.yml b/Resources/Prototypes/Recipes/Lathes/electronics.yml
index 19280a620a..265516c149 100644
--- a/Resources/Prototypes/Recipes/Lathes/electronics.yml
+++ b/Resources/Prototypes/Recipes/Lathes/electronics.yml
@@ -202,6 +202,22 @@
Glass: 900
Gold: 100
+- type: latheRecipe
+ id: AnomalyVesselCircuitboard
+ result: AnomalyVesselCircuitboard
+ completetime: 4
+ materials:
+ Steel: 100
+ Glass: 900
+
+- type: latheRecipe
+ id: APECircuitboard
+ result: APECircuitboard
+ completetime: 4
+ materials:
+ Steel: 100
+ Glass: 900
+
- type: latheRecipe
id: ReagentGrinderMachineCircuitboard
result: ReagentGrinderMachineCircuitboard
diff --git a/Resources/Textures/Objects/Specific/Research/anomalyscanner.rsi/icon.png b/Resources/Textures/Objects/Specific/Research/anomalyscanner.rsi/icon.png
new file mode 100644
index 0000000000..31706aeb91
Binary files /dev/null and b/Resources/Textures/Objects/Specific/Research/anomalyscanner.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Specific/Research/anomalyscanner.rsi/meta.json b/Resources/Textures/Objects/Specific/Research/anomalyscanner.rsi/meta.json
new file mode 100644
index 0000000000..76eff64a56
--- /dev/null
+++ b/Resources/Textures/Objects/Specific/Research/anomalyscanner.rsi/meta.json
@@ -0,0 +1,14 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC0-1.0",
+ "copyright": "Created by EmoGarbage",
+ "states": [
+ {
+ "name": "icon"
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/base.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/base.png
new file mode 100644
index 0000000000..b53ee643c1
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/base.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/inserting.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/inserting.png
new file mode 100644
index 0000000000..aeb74f2c86
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/inserting.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/meta.json b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/meta.json
new file mode 100644
index 0000000000..cd4793cdd2
--- /dev/null
+++ b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/meta.json
@@ -0,0 +1,47 @@
+{
+ "version":1,
+ "size":{
+ "x":96,
+ "y":96
+ },
+ "license":"CC0-1.0",
+ "copyright":"Created by EmoGarbage",
+ "states":[
+ {
+ "name":"base"
+ },
+ {
+ "name": "inserting",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "panel"
+ },
+ {
+ "name": "unshaded",
+ "delays": [
+ [
+ 1.0,
+ 0.25,
+ 0.25,
+ 0.25,
+ 0.25,
+ 0.25,
+ 0.25,
+ 0.5
+ ]
+ ]
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/panel.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/panel.png
new file mode 100644
index 0000000000..234adcd5ee
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/panel.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/unshaded.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/unshaded.png
new file mode 100644
index 0000000000..6608244157
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_generator.rsi/unshaded.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/anomaly.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/anomaly.png
new file mode 100644
index 0000000000..e649b36f53
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/anomaly.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/base.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/base.png
new file mode 100644
index 0000000000..e292496fbe
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/base.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/meta.json b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/meta.json
new file mode 100644
index 0000000000..1a9a61933d
--- /dev/null
+++ b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/meta.json
@@ -0,0 +1,23 @@
+{
+ "version": 1,
+ "license": "CC0-1.0",
+ "copyright": "Created by EmoGarbage404",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "anomaly"
+ },
+ {
+ "name": "base"
+ },
+ {
+ "name": "panel"
+ },
+ {
+ "name": "powered"
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/panel.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/panel.png
new file mode 100644
index 0000000000..b350a50415
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/panel.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/powered.png b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/powered.png
new file mode 100644
index 0000000000..2ed947de62
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/anomaly_vessel.rsi/powered.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/base.png b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/base.png
new file mode 100644
index 0000000000..86c899fd4c
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/base.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/firing.png b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/firing.png
new file mode 100644
index 0000000000..20c993ec39
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/firing.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/locked.png b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/locked.png
new file mode 100644
index 0000000000..fa85ff1d52
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/locked.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/meta.json b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/meta.json
new file mode 100644
index 0000000000..2a826897e4
--- /dev/null
+++ b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/meta.json
@@ -0,0 +1,35 @@
+{
+ "version": 1,
+ "license": "CC0-1.0",
+ "copyright": "Created by EmoGarbage404",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "base",
+ "directions": 4
+ },
+ {
+ "name": "firing",
+ "directions": 4
+ },
+ {
+ "name": "locked",
+ "directions": 4
+ },
+ {
+ "name": "panel",
+ "directions": 4
+ },
+ {
+ "name": "underpowered",
+ "directions": 4
+ },
+ {
+ "name": "unshaded",
+ "directions": 4
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/panel.png b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/panel.png
new file mode 100644
index 0000000000..dd170d14cd
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/panel.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/underpowered.png b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/underpowered.png
new file mode 100644
index 0000000000..9cc2d9d5a2
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/underpowered.png differ
diff --git a/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/unshaded.png b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/unshaded.png
new file mode 100644
index 0000000000..b5fb9bb69e
Binary files /dev/null and b/Resources/Textures/Structures/Machines/Anomaly/ape.rsi/unshaded.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom1-pulse.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom1-pulse.png
new file mode 100644
index 0000000000..3ea4f894bf
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom1-pulse.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom1.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom1.png
new file mode 100644
index 0000000000..0075a07a51
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom1.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom2-pulse.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom2-pulse.png
new file mode 100644
index 0000000000..354ea7bf70
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom2-pulse.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom2.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom2.png
new file mode 100644
index 0000000000..91b21edf71
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom2.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom3-pulse.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom3-pulse.png
new file mode 100644
index 0000000000..ab138701ab
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom3-pulse.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom3.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom3.png
new file mode 100644
index 0000000000..b35962c9de
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom3.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom4-pulse.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom4-pulse.png
new file mode 100644
index 0000000000..488dd67f1f
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom4-pulse.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/anom4.png b/Resources/Textures/Structures/Specific/anomaly.rsi/anom4.png
new file mode 100644
index 0000000000..5f362aa4ac
Binary files /dev/null and b/Resources/Textures/Structures/Specific/anomaly.rsi/anom4.png differ
diff --git a/Resources/Textures/Structures/Specific/anomaly.rsi/meta.json b/Resources/Textures/Structures/Specific/anomaly.rsi/meta.json
new file mode 100644
index 0000000000..f9d4be792f
--- /dev/null
+++ b/Resources/Textures/Structures/Specific/anomaly.rsi/meta.json
@@ -0,0 +1,97 @@
+{
+ "version": 1,
+ "license": "CC0-1.0",
+ "copyright": "Created by EmoGarbage; anom3, anom3-pulse, anom4, anom4-pulse are CC-BY-SA-3.0 at https://github.com/ParadiseSS13/Paradise/blob/master/icons/effects/effects.dmi",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "anom1"
+ },
+ {
+ "name": "anom1-pulse",
+ "delays": [
+ [
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625
+ ]
+ ]
+ },
+ {
+ "name": "anom2"
+ },
+ {
+ "name": "anom2-pulse",
+ "delays": [
+ [
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625,
+ 0.15625
+ ]
+ ]
+ },
+ {
+ "name": "anom3"
+ },
+ {
+ "name": "anom3-pulse",
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ },
+ {
+ "name": "anom4",
+ "delays": [
+ [
+ 0.3,
+ 0.3,
+ 0.3,
+ 0.3,
+ 0.3,
+ 0.3,
+ 0.3
+ ]
+ ]
+ },
+ {
+ "name": "anom4-pulse",
+ "delays": [
+ [
+ 0.15,
+ 0.15,
+ 0.15,
+ 0.15,
+ 0.15,
+ 0.15,
+ 0.15
+ ]
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Resources/Textures/Structures/Wallmounts/signs.rsi/anomaly2.png b/Resources/Textures/Structures/Wallmounts/signs.rsi/anomaly2.png
new file mode 100644
index 0000000000..73d94ac95e
Binary files /dev/null and b/Resources/Textures/Structures/Wallmounts/signs.rsi/anomaly2.png differ
diff --git a/Resources/Textures/Structures/Wallmounts/signs.rsi/meta.json b/Resources/Textures/Structures/Wallmounts/signs.rsi/meta.json
index 7fb0e4a735..209c9c7d75 100644
--- a/Resources/Textures/Structures/Wallmounts/signs.rsi/meta.json
+++ b/Resources/Textures/Structures/Wallmounts/signs.rsi/meta.json
@@ -23,6 +23,9 @@
]
]
},
+ {
+ "name": "anomaly2"
+ },
{
"name": "armory",
"delays": [