diff --git a/Content.Server/StationEvents/Components/VentHordeRuleComponent.cs b/Content.Server/StationEvents/Components/VentHordeRuleComponent.cs
new file mode 100644
index 0000000000..b4a7b49e6c
--- /dev/null
+++ b/Content.Server/StationEvents/Components/VentHordeRuleComponent.cs
@@ -0,0 +1,26 @@
+using Content.Server.StationEvents.Events;
+using Content.Shared.EntityTable.EntitySelectors;
+
+namespace Content.Server.StationEvents.Components;
+
+///
+/// Component used for the vent horde gamerule.
+/// Picks a random entity with
+/// and spawns entities picked from the on it after a delay.
+///
+[RegisterComponent, Access(typeof(VentHordeRule))]
+public sealed partial class VentHordeRuleComponent : Component
+{
+ ///
+ /// The table of possible mobs to spawn from the vent.
+ ///
+ [DataField(required: true)]
+ public EntityTableSelector Table = default!;
+
+ ///
+ /// The vent that has been chosen to spawn the entities.
+ /// Spawning logic is handled by
+ ///
+ [DataField]
+ public EntityUid? ChosenVent;
+}
diff --git a/Content.Server/StationEvents/Events/VentCrittersRule.cs b/Content.Server/StationEvents/Events/VentCrittersRule.cs
index 5ca5a9d215..c8113c3098 100644
--- a/Content.Server/StationEvents/Events/VentCrittersRule.cs
+++ b/Content.Server/StationEvents/Events/VentCrittersRule.cs
@@ -27,6 +27,9 @@ public sealed class VentCrittersRule : StationEventSystem();
while (locations.MoveNext(out _, out _, out var transform))
{
+ if (!transform.Anchored)
+ continue;
+
if (CompOrNull(transform.GridUid)?.Station == station)
{
validLocations.Add(transform.Coordinates);
diff --git a/Content.Server/StationEvents/Events/VentHordeRule.cs b/Content.Server/StationEvents/Events/VentHordeRule.cs
new file mode 100644
index 0000000000..230b93c548
--- /dev/null
+++ b/Content.Server/StationEvents/Events/VentHordeRule.cs
@@ -0,0 +1,122 @@
+using System.Linq;
+using Content.Server.Pinpointer;
+using Content.Server.StationEvents.Components;
+using Content.Server.VentHorde.Components;
+using Content.Server.VentHorde.Systems;
+using Content.Shared.EntityTable;
+using Content.Shared.GameTicking.Components;
+using Content.Shared.Station.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.StationEvents.Events;
+
+///
+/// Variant of that selects a single vent and spawns all entities there.
+///
+public sealed class VentHordeRule : StationEventSystem
+{
+ /*
+ * DO NOT COPY PASTE THIS TO MAKE YOUR MOB EVENT.
+ * USE THE PROTOTYPE.
+ */
+
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly NavMapSystem _navMap = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+ [Dependency] private readonly EntityTableSystem _table = default!;
+ [Dependency] private readonly VentHordeSystem _horde = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ protected override void Added(EntityUid uid, VentHordeRuleComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args)
+ {
+ // Choose location and make sure it's not null
+ component.ChosenVent = ChooseVent();
+
+ if (component.ChosenVent is not { } vent)
+ {
+ Log.Warning($"Unable to find a valid vent for {args.RuleId}!");
+ ForceEndSelf(uid, gameRule);
+ return;
+ }
+
+ // Get the event component so we can format the announcement
+ if (TryComp(uid, out var stationEventComp) && stationEventComp.StartAnnouncement != null)
+ {
+ // Get the nearest beacon
+ var mapLocation = _transform.ToMapCoordinates(Transform(vent).Coordinates);
+ var nearestBeacon = _navMap.GetNearestBeaconString(mapLocation, onlyName: true);
+
+ // Format the announcement with the location, if the string doesn't have them it'll still work fine
+ // time is not said on purpose to keep the players on their toes.
+ // also because we cannot tell the end time inside of Added().
+ stationEventComp.StartAnnouncement =
+ Loc.GetString(stationEventComp.StartAnnouncement,
+ ("location", nearestBeacon));
+ }
+
+ base.Added(uid, component, gameRule, args);
+ }
+
+ protected override void Started(EntityUid uid, VentHordeRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
+ {
+ base.Started(uid, component, gameRule, args);
+
+ if (!Exists(component.ChosenVent))
+ {
+ Log.Warning($"Chosen vent for {args.RuleId} does not exist!");
+ ForceEndSelf(uid, gameRule);
+ return;
+ }
+
+ if (!TryComp(uid, out var stationEventComp))
+ return;
+
+ // We grab when the gamerule is expected to end and subtract the current time from it to get the duration.
+ var duration = (stationEventComp.EndTime - _timing.CurTime) ?? TimeSpan.Zero;
+
+ var spawns = _table.GetSpawns(component.Table);
+
+ if (component.ChosenVent == null)
+ return;
+
+ // And start the spawn at the chosen vent.
+ // The duration is the same as the time until expected gamerule end time, but that is only for convenience.
+ // The spawn can happen early in certain circumstances anyway.
+ _horde.StartHordeSpawn(component.ChosenVent.Value, spawns.ToList(), duration);
+ }
+
+ private EntityUid? ChooseVent()
+ {
+ // Get a station
+ if (!TryGetRandomStation(out var station))
+ {
+ return null;
+ }
+
+ // Query the possible locations
+ var locations = EntityQueryEnumerator();
+ var validLocations = new List();
+
+ // Filter to things on the same station
+ while (locations.MoveNext(out var uid, out _, out var transform))
+ {
+ if (!transform.Anchored)
+ continue;
+
+ if (HasComp(uid))
+ continue;
+
+ if (CompOrNull(transform.GridUid)?.Station == station)
+ {
+ validLocations.Add(uid);
+ }
+ }
+
+ // Pick one at random
+ if (validLocations.Count != 0)
+ return _random.Pick(validLocations);
+
+ return null;
+ }
+}
diff --git a/Content.Server/VentHorde/Components/VentHordeSpawnerComponent.cs b/Content.Server/VentHorde/Components/VentHordeSpawnerComponent.cs
new file mode 100644
index 0000000000..ab1d68031c
--- /dev/null
+++ b/Content.Server/VentHorde/Components/VentHordeSpawnerComponent.cs
@@ -0,0 +1,77 @@
+using Content.Server.VentHorde.Systems;
+using Robust.Shared.Audio;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.VentHorde.Components;
+
+///
+/// Marks an entity as selected by the .
+/// Will spawn all entities contained within on its location at .
+///
+[RegisterComponent, Access(typeof(VentHordeSystem))]
+[AutoGenerateComponentPause]
+public sealed partial class VentHordeSpawnerComponent : Component
+{
+ ///
+ /// The mobs to spawn from the vent.
+ ///
+ [DataField(required: true)]
+ public List Entities = new ();
+
+ ///
+ /// Maximum speed at which the entities will be thrown out of the vent.
+ ///
+ [DataField]
+ public float MaxThrowSpeed = 1.5f;
+
+ ///
+ /// Minimum speed at which the entities will be thrown out of the vent.
+ ///
+ [DataField]
+ public float MinThrowSpeed = 0.5f;
+
+ ///
+ /// Maximum distance which travel when thrown out of the vent.
+ ///
+ [DataField]
+ public float MaxThrowDistance = 4f;
+
+ ///
+ /// Minimum distance which travel when thrown out of the vent.
+ ///
+ [DataField]
+ public float MinThrowDistance = 2f;
+
+ ///
+ /// The time at which the entities will spawn.
+ ///
+ [DataField(customTypeSerializer:typeof(TimeOffsetSerializer))]
+ [AutoPausedField]
+ public TimeSpan? SpawnTime;
+
+ ///
+ /// Plays on loop when a vent is selected as a spawner.
+ ///
+ [DataField]
+ public SoundSpecifier PassiveSound = new SoundPathSpecifier("/Audio/Machines/airlock_creaking.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-3f),
+ };
+
+ ///
+ /// Plays when the entities are thrown out of the vent.
+ ///
+ [DataField]
+ public SoundSpecifier EndSound = new SoundPathSpecifier("/Audio/Weapons/Guns/Gunshots/grenade_launcher.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-3f),
+ };
+
+ ///
+ /// The PassiveSound entity, used to cancel the audio.
+ ///
+ [DataField]
+ [ViewVariables(VVAccess.ReadOnly)]
+ public EntityUid? AudioStream;
+}
diff --git a/Content.Server/VentHorde/Systems/VentHordeSystem.cs b/Content.Server/VentHorde/Systems/VentHordeSystem.cs
new file mode 100644
index 0000000000..a2834e0145
--- /dev/null
+++ b/Content.Server/VentHorde/Systems/VentHordeSystem.cs
@@ -0,0 +1,119 @@
+using Content.Server.VentHorde.Components;
+using Content.Shared.Destructible;
+using Content.Shared.Jittering;
+using Content.Shared.Throwing;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.VentHorde.Systems;
+
+public sealed class VentHordeSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly ThrowingSystem _throwing = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedJitteringSystem _jitter = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnSpawnerInit);
+ SubscribeLocalEvent(OnSpawnerShutdown);
+
+ SubscribeLocalEvent(OnSpawnerBreakage);
+ SubscribeLocalEvent(OnSpawnerAnchored);
+ }
+
+ private void OnSpawnerInit(Entity entity, ref MapInitEvent args)
+ {
+ _jitter.AddJitter(entity);
+ }
+
+ private void OnSpawnerShutdown(Entity entity, ref ComponentShutdown args)
+ {
+ _audio.Stop(entity.Comp.AudioStream);
+ RemCompDeferred(entity);
+ }
+
+ private void OnSpawnerBreakage(Entity entity, ref BreakageEventArgs args)
+ {
+ // There is no escape.
+ EndHordeSpawn(entity);
+ }
+
+ private void OnSpawnerAnchored(Entity entity, ref AnchorStateChangedEvent args)
+ {
+ // Anchor state changes when the entity is broken, to avoid double spawning we check if the entity is gonna be deleted.
+ if (TerminatingOrDeleted(entity))
+ return;
+
+ // There is no escape.
+ EndHordeSpawn(entity);
+ }
+
+ ///
+ /// Starts a horde spawn at an entity.
+ ///
+ /// The entity to spawn the horde at.
+ /// List of entities to spawn.
+ /// Time after which to spawn the entities.
+ /// If an already active spawner is selected, will add entities to its list. Otherwise, will fail.
+ public void StartHordeSpawn(EntityUid uid, List spawns, TimeSpan spawnDelay, bool append = true)
+ {
+ if (TryComp(uid, out var hordeSpawner))
+ {
+ if (append)
+ {
+ hordeSpawner.Entities.AddRange(spawns);
+ }
+
+ return;
+ }
+
+ hordeSpawner = EnsureComp(uid);
+
+ hordeSpawner.AudioStream = _audio.PlayPvs(hordeSpawner.PassiveSound, uid, hordeSpawner.PassiveSound.Params.WithLoop(true))?.Entity;
+
+ hordeSpawner.Entities = spawns;
+ hordeSpawner.SpawnTime = _timing.CurTime + spawnDelay;
+ }
+
+ ///
+ /// Ends a horde spawn, causing all entities to spawn at once.
+ ///
+ /// The horde spawner entity.
+ public void EndHordeSpawn(Entity entity)
+ {
+ entity.Comp.AudioStream = _audio.Stop(entity.Comp.AudioStream);
+
+ _audio.PlayPvs(entity.Comp.EndSound, entity);
+
+ foreach (var spawn in entity.Comp.Entities)
+ {
+ var spawned = Spawn(spawn, Transform(entity).Coordinates);
+ var direction = _random.NextVector2() * _random.NextFloat(entity.Comp.MinThrowDistance, entity.Comp.MaxThrowDistance);
+ var throwSpeed = _random.NextFloat(entity.Comp.MinThrowSpeed, entity.Comp.MaxThrowSpeed);
+ _throwing.TryThrow(spawned, direction, throwSpeed);
+ }
+
+ RemCompDeferred(entity);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (comp.SpawnTime != null && _timing.CurTime > comp.SpawnTime)
+ {
+ EndHordeSpawn((uid, comp));
+ }
+ }
+ }
+}
diff --git a/Resources/Locale/en-US/station-events/events/vent-critters.ftl b/Resources/Locale/en-US/station-events/events/vent-critters.ftl
index 426f0c0ca1..ce6990d833 100644
--- a/Resources/Locale/en-US/station-events/events/vent-critters.ftl
+++ b/Resources/Locale/en-US/station-events/events/vent-critters.ftl
@@ -1 +1 @@
-station-event-vent-creatures-start-announcement = Attention. A large influx of unknown life forms have been detected residing within the station's ventilation systems. Please be rid of these creatures before it begins to affect productivity.
+station-event-vent-creatures-start-horde-announcement = Attention. A large influx of unknown life forms have been detected moving through the station's ventilation systems. They are expected to emerge near {$location}. Please evacuate the area to avoid loss of personnel.
diff --git a/Resources/Prototypes/GameRules/events.yml b/Resources/Prototypes/GameRules/events.yml
index f680004e87..8c46bae789 100644
--- a/Resources/Prototypes/GameRules/events.yml
+++ b/Resources/Prototypes/GameRules/events.yml
@@ -18,11 +18,11 @@
- id: MouseMigration
- id: PowerGridCheck
- id: RandomSentience
- - id: SlimesSpawn
+ - id: SlimesSpawnHorde
- id: SolarFlare
- - id: SnakeSpawn
- - id: SpiderClownSpawn
- - id: SpiderSpawn
+ - id: SnakeSpawnHorde
+ - id: SpiderClownSpawnHorde
+ - id: SpiderSpawnHorde
- id: VentClog
- type: entityTable
@@ -447,81 +447,87 @@
duration: 60
- type: VentClogRule
+# Critters
+
- type: entity
- id: SlimesSpawn
+ id: SlimesSpawnHorde
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
+ startAnnouncement: station-event-vent-creatures-start-horde-announcement
startAudio:
path: /Audio/Announcements/attention.ogg
earliestStart: 20
minimumPlayers: 15
weight: 5
- duration: 60
- - type: VentCrittersRule
- entries:
- - id: MobAdultSlimesBlueAngry
- prob: 0.02
- - id: MobAdultSlimesGreenAngry
- prob: 0.02
- - id: MobAdultSlimesYellowAngry
- prob: 0.02
+ duration: 5
+ maxDuration: 20
+ - type: VentHordeRule
+ table: !type:GroupSelector
+ rolls: 4, 8
+ children:
+ - id: MobAdultSlimesBlueAngry
+ - id: MobAdultSlimesGreenAngry
+ - id: MobAdultSlimesYellowAngry
- type: entity
- id: SnakeSpawn
+ id: SnakeSpawnHorde
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
+ startAnnouncement: station-event-vent-creatures-start-horde-announcement
startAudio:
path: /Audio/Announcements/attention.ogg
earliestStart: 20
minimumPlayers: 15
weight: 5
- duration: 60
- - type: VentCrittersRule
- entries:
- - id: MobPurpleSnake
- prob: 0.02
- - id: MobSmallPurpleSnake
- prob: 0.02
- - id: MobCobraSpace
- prob: 0.02
+ duration: 5
+ maxDuration: 20
+ - type: VentHordeRule
+ table: !type:GroupSelector
+ rolls: 4, 8
+ children:
+ - id: MobPurpleSnake
+ - id: MobSmallPurpleSnake
+ - id: MobCobraSpace
- type: entity
- id: SpiderSpawn
+ id: SpiderSpawnHorde
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
+ startAnnouncement: station-event-vent-creatures-start-horde-announcement
startAudio:
path: /Audio/Announcements/attention.ogg
earliestStart: 20
minimumPlayers: 15
weight: 5
- duration: 60
- - type: VentCrittersRule
- entries:
- - id: MobGiantSpiderAngry
- prob: 0.05
+ duration: 5
+ maxDuration: 20
+ - type: VentHordeRule
+ table: !type:GroupSelector
+ rolls: 4, 8
+ children:
+ - id: MobGiantSpiderAngry
- type: entity
- id: SpiderClownSpawn
+ id: SpiderClownSpawnHorde
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
+ startAnnouncement: station-event-vent-creatures-start-horde-announcement
startAudio:
path: /Audio/Announcements/attention.ogg
earliestStart: 20
minimumPlayers: 20
weight: 1.5
- duration: 60
- - type: VentCrittersRule
- entries:
- - id: MobClownSpider
- prob: 0.05
+ duration: 5
+ maxDuration: 20
+ - type: VentHordeRule
+ table: !type:GroupSelector
+ rolls: 4, 8
+ children:
+ - id: MobClownSpider
- type: entity
id: ZombieOutbreak
diff --git a/Resources/Prototypes/GameRules/pests.yml b/Resources/Prototypes/GameRules/pests.yml
index 9ddee3d709..7a80b3baa0 100644
--- a/Resources/Prototypes/GameRules/pests.yml
+++ b/Resources/Prototypes/GameRules/pests.yml
@@ -7,6 +7,7 @@
- id: SnailMigrationLowPop
- id: CockroachMigration
- id: MouseMigration
+ - id: RandomCritterHorde
- type: entityTable
id: SpicyPestEventsTable
@@ -22,9 +23,6 @@
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
- startAudio:
- path: /Audio/Announcements/attention.ogg
earliestStart: 15
weight: 6
duration: 50
@@ -45,14 +43,11 @@
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
- startAudio:
- path: /Audio/Announcements/attention.ogg
earliestStart: 15
weight: 6
duration: 50
minimumPlayers: 30 # Hopefully this is enough for the Rat King's potential Army (it was not, raised from 15 -> 30)
- - type: VentCrittersRule
+ - type: VentCrittersRule # We don't use Horde here to make RK spawns less predictable.
entries:
- id: MobMouse
prob: 0.02
@@ -71,9 +66,6 @@
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
- startAudio:
- path: /Audio/Announcements/attention.ogg
weight: 6
duration: 50
- type: VentCrittersRule
@@ -90,9 +82,6 @@
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
- startAudio:
- path: /Audio/Announcements/attention.ogg
weight: 6
duration: 50
- type: VentCrittersRule
@@ -109,9 +98,6 @@
parent: BaseStationEventShortDelay
components:
- type: StationEvent
- startAnnouncement: station-event-vent-creatures-start-announcement
- startAudio:
- path: /Audio/Announcements/attention.ogg
earliestStart: 15
weight: 6
duration: 50
@@ -125,4 +111,38 @@
- id: MobSnailMoth
prob: 0.002
- id: MobSnailInstantDeath
- prob: 0.00001 # ~ 1:2000 snails
\ No newline at end of file
+ prob: 0.00001 # ~ 1:2000 snails
+
+- type: entity
+ parent: BaseStationEventShortDelay
+ id: RandomCritterHorde # My deepest apologies, Mr. Secoff. You just wasted your time :)
+ components:
+ - type: StationEvent
+ startAnnouncement: station-event-vent-creatures-start-horde-announcement
+ startAudio:
+ path: /Audio/Announcements/attention.ogg
+ earliestStart: 15
+ weight: 6 # Slightly more common than other hordes
+ duration: 5
+ maxDuration: 20
+ reoccurrenceDelay: 15 # Shorter because this event is basically a nat zero on the round. It only exists to potentially confuse security or the crew
+ - type: VentHordeRule
+ table: !type:GroupSelector
+ rolls: 3,5
+ children:
+ - !type:GroupSelector
+ children: # Increased chances for special snails on purpose. Makes it a bit more interesting.
+ - id: MobSnail
+ - id: MobSnailSpeed
+ - id: MobSnailMoth
+ - !type:GroupSelector
+ children: # Increased chances for special roaches on purpose. Makes it a bit more interesting.
+ - id: MobCockroach
+ - id: MobMothroach
+ - id: MobMoproach
+ weight: 0.5
+ - !type:GroupSelector
+ children:
+ - id: MobMouse
+ - id: MobMouse1
+ - id: MobMouse2