diff --git a/Content.Server/Physics/Components/RandomWalkComponent.cs b/Content.Server/Physics/Components/RandomWalkComponent.cs
index e017f61ddd..90f035b84d 100644
--- a/Content.Server/Physics/Components/RandomWalkComponent.cs
+++ b/Content.Server/Physics/Components/RandomWalkComponent.cs
@@ -1,3 +1,4 @@
+using System.Numerics;
using Content.Server.Physics.Controllers;
namespace Content.Server.Physics.Components;
@@ -29,6 +30,18 @@ public sealed partial class RandomWalkComponent : Component
[ViewVariables(VVAccess.ReadWrite)]
public float AccumulatorRatio = 0.0f;
+ ///
+ /// The vector by which the random walk direction is biased.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public Vector2 BiasVector = new Vector2(0f, 0f);
+
+ ///
+ /// Whether to set BiasVector to (0, 0) every random walk update.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public bool ResetBiasOnWalk = true;
+
///
/// Whether this random walker should take a step immediately when it starts up.
///
diff --git a/Content.Server/Physics/Controllers/RandomWalkController.cs b/Content.Server/Physics/Controllers/RandomWalkController.cs
index 4a93a9e706..61d393c488 100644
--- a/Content.Server/Physics/Controllers/RandomWalkController.cs
+++ b/Content.Server/Physics/Controllers/RandomWalkController.cs
@@ -1,3 +1,4 @@
+using System.Numerics;
using Content.Server.Physics.Components;
using Content.Shared.Follower.Components;
using Content.Shared.Throwing;
@@ -69,11 +70,15 @@ internal sealed class RandomWalkController : VirtualController
if(!Resolve(uid, ref physics))
return;
- var pushAngle = _random.NextAngle();
+ var pushVec = _random.NextAngle().ToVec();
+ pushVec += randomWalk.BiasVector;
+ pushVec.Normalize();
+ if (randomWalk.ResetBiasOnWalk)
+ randomWalk.BiasVector *= 0f;
var pushStrength = _random.NextFloat(randomWalk.MinSpeed, randomWalk.MaxSpeed);
_physics.SetLinearVelocity(uid, physics.LinearVelocity * randomWalk.AccumulatorRatio, body: physics);
- _physics.ApplyLinearImpulse(uid, pushAngle.ToVec() * (pushStrength * physics.Mass), body: physics);
+ _physics.ApplyLinearImpulse(uid, pushVec * (pushStrength * physics.Mass), body: physics);
}
///
diff --git a/Content.Server/Singularity/Components/SingularityAttractorComponent.cs b/Content.Server/Singularity/Components/SingularityAttractorComponent.cs
new file mode 100644
index 0000000000..d5adfd92c1
--- /dev/null
+++ b/Content.Server/Singularity/Components/SingularityAttractorComponent.cs
@@ -0,0 +1,30 @@
+using Content.Server.Singularity.EntitySystems;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Singularity.Components;
+
+///
+/// Attracts the singularity.
+///
+[RegisterComponent]
+[Access(typeof(SingularityAttractorSystem))]
+public sealed partial class SingularityAttractorComponent : Component
+{
+ ///
+ /// The range at which singularities will be unable to go away from the attractor.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public float BaseRange = 25f;
+
+ ///
+ /// The amount of time that should elapse between pulses of this attractor.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadOnly)]
+ public TimeSpan TargetPulsePeriod = TimeSpan.FromSeconds(2);
+
+ ///
+ /// The last time this attractor pulsed.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ public TimeSpan LastPulseTime = default!;
+}
diff --git a/Content.Server/Singularity/EntitySystems/SingularityAttractorSystem.cs b/Content.Server/Singularity/EntitySystems/SingularityAttractorSystem.cs
new file mode 100644
index 0000000000..3c44a7fc7a
--- /dev/null
+++ b/Content.Server/Singularity/EntitySystems/SingularityAttractorSystem.cs
@@ -0,0 +1,106 @@
+using Content.Server.Physics.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Server.Singularity.Components;
+using Content.Shared.Singularity.Components;
+using Content.Shared.Singularity.EntitySystems;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Physics.Systems;
+using Robust.Shared.Timing;
+using System.Numerics;
+
+namespace Content.Server.Singularity.EntitySystems;
+
+///
+/// Handles singularity attractors.
+///
+public sealed class SingularityAttractorSystem : EntitySystem
+{
+ [Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ ///
+ /// The minimum range at which the attraction will act.
+ /// Prevents division by zero problems.
+ ///
+ public const float MinAttractRange = 0.00001f;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMapInit);
+ }
+
+ ///
+ /// Updates the pulse cooldowns of all singularity attractors.
+ /// If they are off cooldown it makes them emit an attraction pulse and reset their cooldown.
+ ///
+ /// The time elapsed since the last set of updates.
+ public override void Update(float frameTime)
+ {
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ var query = EntityQueryEnumerator();
+ var now = _timing.CurTime;
+ while (query.MoveNext(out var uid, out var attractor, out var xform))
+ {
+ if (attractor.LastPulseTime + attractor.TargetPulsePeriod <= now)
+ Update(uid, attractor, xform);
+ }
+ }
+
+ ///
+ /// Makes an attractor attract all singularities and puts it on cooldown.
+ ///
+ /// The uid of the attractor to make pulse.
+ /// The state of the attractor to make pulse.
+ /// The transform of the attractor to make pulse.
+ private void Update(EntityUid uid, SingularityAttractorComponent? attractor = null, TransformComponent? xform = null)
+ {
+ if (!Resolve(uid, ref attractor, ref xform))
+ return;
+
+ if (!this.IsPowered(uid, EntityManager))
+ return;
+
+ attractor.LastPulseTime = _timing.CurTime;
+
+ var mapPos = xform.Coordinates.ToMap(EntityManager);
+
+ if (mapPos == MapCoordinates.Nullspace)
+ return;
+
+ var query = EntityQuery();
+ foreach (var (singulo, walk, singuloXform) in query)
+ {
+ var singuloMapPos = singuloXform.Coordinates.ToMap(EntityManager);
+
+ if (singuloMapPos.MapId != mapPos.MapId)
+ continue;
+
+ var biasBy = mapPos.Position - singuloMapPos.Position;
+ var length = biasBy.Length();
+ if (length <= MinAttractRange)
+ return;
+
+ biasBy = Vector2.Normalize(biasBy) * (attractor.BaseRange / length);
+
+ walk.BiasVector += biasBy;
+ }
+ }
+
+ ///
+ /// Resets the pulse timings of the attractor when the component starts up.
+ ///
+ /// The uid of the attractor to start up.
+ /// The state of the attractor to start up.
+ /// The startup prompt arguments.
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
+ {
+ ent.Comp.LastPulseTime = _timing.CurTime;
+ }
+}
diff --git a/Resources/Locale/en-US/store/uplink-catalog.ftl b/Resources/Locale/en-US/store/uplink-catalog.ftl
index 50823a5897..746ae3d976 100644
--- a/Resources/Locale/en-US/store/uplink-catalog.ftl
+++ b/Resources/Locale/en-US/store/uplink-catalog.ftl
@@ -142,6 +142,9 @@ uplink-voice-mask-desc = A gas mask that lets you adjust your voice to whoever y
uplink-radio-jammer-name = Radio Jammer
uplink-radio-jammer-desc = This device will disrupt any nearby outgoing radio communication when activated.
+uplink-singularity-beacon-name = Singularity Beacon
+uplink-singularity-beacon-desc = A device that attracts singularities. Has to be anchored and powered. Causes singularities to grow when consumed.
+
# Implants
uplink-storage-implanter-name = Storage Implanter
uplink-storage-implanter-desc = Hide goodies inside of yourself with new bluespace technology!
diff --git a/Resources/Prototypes/Catalog/uplink_catalog.yml b/Resources/Prototypes/Catalog/uplink_catalog.yml
index 36c3c1435f..aa6bf74096 100644
--- a/Resources/Prototypes/Catalog/uplink_catalog.yml
+++ b/Resources/Prototypes/Catalog/uplink_catalog.yml
@@ -1123,6 +1123,16 @@
- ResearchDirector
- Chef
+- type: listing
+ id: UplinkSingarityBeacon
+ name: uplink-singularity-beacon-name
+ description: uplink-singularity-beacon-desc
+ productEntity: SingularityBeacon
+ cost:
+ Telecrystal: 12
+ categories:
+ - UplinkUtility
+
# Armor
- type: listing
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/singularity_beacon.yml b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/singularity_beacon.yml
new file mode 100644
index 0000000000..372b689113
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/singularity_beacon.yml
@@ -0,0 +1,40 @@
+- type: entity
+ id: SingularityBeacon
+ parent: BaseMachinePowered
+ name: singularity beacon
+ description: A syndicate device that attracts the singularity. If it's loose and you're seeing this, run.
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/singularity_beacon.rsi
+ layers:
+ - state: icon
+ - type: Item
+ size: Huge
+ - type: Fixtures
+ fixtures:
+ fix1:
+ shape:
+ !type:PhysShapeAabb
+ bounds: "-0.40,-0.40,0.40,0.40"
+ density: 80
+ mask:
+ - MachineMask
+ layer:
+ - MachineLayer
+ - type: SingularityAttractor
+ baseRange: 80
+ - type: SinguloFood
+ energy: 120
+ - type: Destructible
+ thresholds:
+ - trigger:
+ !type:DamageTrigger
+ damage: 50
+ behaviors:
+ - !type:DoActsBehavior
+ acts: [ "Destruction" ]
+ - type: ApcPowerReceiver
+ powerLoad: 15000
+ priority: High
+ - type: StaticPrice
+ price: 1500
diff --git a/Resources/Textures/Objects/Devices/singularity_beacon.rsi/icon.png b/Resources/Textures/Objects/Devices/singularity_beacon.rsi/icon.png
new file mode 100644
index 0000000000..78bb95b874
Binary files /dev/null and b/Resources/Textures/Objects/Devices/singularity_beacon.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Devices/singularity_beacon.rsi/meta.json b/Resources/Textures/Objects/Devices/singularity_beacon.rsi/meta.json
new file mode 100644
index 0000000000..2347b239a1
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/singularity_beacon.rsi/meta.json
@@ -0,0 +1,20 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "tgstation at https://github.com/tgstation/tgstation/blob/c46d689e2b300bdc8c5b153855b1a5bbb2c5b168/icons/obj/singularity.dmi",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon",
+ "delays": [
+ [
+ 0.1,
+ 0.1
+ ]
+ ]
+ }
+ ]
+}