Vent Hordes (#43047)

* init

* port hostiles to hordes, fakeout horde

* cleaning, docs

* i may be blind sometimes

* i may be even blinder

* remove friendly announcements

* fuck it

* i love microbalancing

* review

* review

* review p2

* Update Resources/Prototypes/GameRules/pests.yml

Co-authored-by: SnappingOpossum <snappingopossum@outlook.com>

* review + powercreep

* lower the times to account for the gamerule delay

* review

* review

* fuck this return actually

* fial

---------

Co-authored-by: SnappingOpossum <snappingopossum@outlook.com>

# Conflicts:
#	Resources/Prototypes/GameRules/pests.yml
This commit is contained in:
ScarKy0 2026-03-12 00:28:47 +01:00 committed by Coryler
parent 86c4c900eb
commit 9b5a09e8a8
8 changed files with 420 additions and 30 deletions

View File

@ -0,0 +1,26 @@
using Content.Server.StationEvents.Events;
using Content.Shared.EntityTable.EntitySelectors;
namespace Content.Server.StationEvents.Components;
/// <summary>
/// Component used for the vent horde gamerule.
/// Picks a random entity with <see cref="VentCritterSpawnLocationComponent"/>
/// and spawns entities picked from the <see cref="Table"/> on it after a delay.
/// </summary>
[RegisterComponent, Access(typeof(VentHordeRule))]
public sealed partial class VentHordeRuleComponent : Component
{
/// <summary>
/// The table of possible mobs to spawn from the vent.
/// </summary>
[DataField(required: true)]
public EntityTableSelector Table = default!;
/// <summary>
/// The vent that has been chosen to spawn the entities.
/// Spawning logic is handled by <see cref="VentHordeSpawnerComponent"/>
/// </summary>
[DataField]
public EntityUid? ChosenVent;
}

View File

@ -90,6 +90,9 @@ public sealed class VentCrittersRule : StationEventSystem<VentCrittersRuleCompon
_locations.Clear();
while (locations.MoveNext(out var uid, out _, out var transform))
{
if (!transform.Anchored)
continue;
if (CompOrNull<StationMemberComponent>(transform.GridUid)?.Station == station)
{
_locations.Add(transform.Coordinates);

View File

@ -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;
/// <summary>
/// Variant of <see cref="VentCrittersRule"/> that selects a single vent and spawns all entities there.
/// </summary>
public sealed class VentHordeRule : StationEventSystem<VentHordeRuleComponent>
{
/*
* 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<StationEventComponent>(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<StationEventComponent>(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<VentCritterSpawnLocationComponent, TransformComponent>();
var validLocations = new List<EntityUid>();
// Filter to things on the same station
while (locations.MoveNext(out var uid, out _, out var transform))
{
if (!transform.Anchored)
continue;
if (HasComp<VentHordeSpawnerComponent>(uid))
continue;
if (CompOrNull<StationMemberComponent>(transform.GridUid)?.Station == station)
{
validLocations.Add(uid);
}
}
// Pick one at random
if (validLocations.Count != 0)
return _random.Pick(validLocations);
return null;
}
}

View File

@ -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;
/// <summary>
/// Marks an entity as selected by the <see cref="VentHordeRuleComponent"/>.
/// Will spawn all entities contained within <see cref="Entities"/> on its location at <see cref="SpawnTime"/>.
/// </summary>
[RegisterComponent, Access(typeof(VentHordeSystem))]
[AutoGenerateComponentPause]
public sealed partial class VentHordeSpawnerComponent : Component
{
/// <summary>
/// The mobs to spawn from the vent.
/// </summary>
[DataField(required: true)]
public List<EntProtoId> Entities = new ();
/// <summary>
/// Maximum speed at which the entities will be thrown out of the vent.
/// </summary>
[DataField]
public float MaxThrowSpeed = 1.5f;
/// <summary>
/// Minimum speed at which the entities will be thrown out of the vent.
/// </summary>
[DataField]
public float MinThrowSpeed = 0.5f;
/// <summary>
/// Maximum distance which travel when thrown out of the vent.
/// </summary>
[DataField]
public float MaxThrowDistance = 4f;
/// <summary>
/// Minimum distance which travel when thrown out of the vent.
/// </summary>
[DataField]
public float MinThrowDistance = 2f;
/// <summary>
/// The time at which the entities will spawn.
/// </summary>
[DataField(customTypeSerializer:typeof(TimeOffsetSerializer))]
[AutoPausedField]
public TimeSpan? SpawnTime;
/// <summary>
/// Plays on loop when a vent is selected as a spawner.
/// </summary>
[DataField]
public SoundSpecifier PassiveSound = new SoundPathSpecifier("/Audio/Machines/airlock_creaking.ogg")
{
Params = AudioParams.Default.WithVolume(-3f),
};
/// <summary>
/// Plays when the entities are thrown out of the vent.
/// </summary>
[DataField]
public SoundSpecifier EndSound = new SoundPathSpecifier("/Audio/Weapons/Guns/Gunshots/grenade_launcher.ogg")
{
Params = AudioParams.Default.WithVolume(-3f),
};
/// <summary>
/// The PassiveSound entity, used to cancel the audio.
/// </summary>
[DataField]
[ViewVariables(VVAccess.ReadOnly)]
public EntityUid? AudioStream;
}

View File

@ -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<VentHordeSpawnerComponent, MapInitEvent>(OnSpawnerInit);
SubscribeLocalEvent<VentHordeSpawnerComponent, ComponentShutdown>(OnSpawnerShutdown);
SubscribeLocalEvent<VentHordeSpawnerComponent, BreakageEventArgs>(OnSpawnerBreakage);
SubscribeLocalEvent<VentHordeSpawnerComponent, AnchorStateChangedEvent>(OnSpawnerAnchored);
}
private void OnSpawnerInit(Entity<VentHordeSpawnerComponent> entity, ref MapInitEvent args)
{
_jitter.AddJitter(entity);
}
private void OnSpawnerShutdown(Entity<VentHordeSpawnerComponent> entity, ref ComponentShutdown args)
{
_audio.Stop(entity.Comp.AudioStream);
RemCompDeferred<JitteringComponent>(entity);
}
private void OnSpawnerBreakage(Entity<VentHordeSpawnerComponent> entity, ref BreakageEventArgs args)
{
// There is no escape.
EndHordeSpawn(entity);
}
private void OnSpawnerAnchored(Entity<VentHordeSpawnerComponent> 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);
}
/// <summary>
/// Starts a horde spawn at an entity.
/// </summary>
/// <param name="uid">The entity to spawn the horde at.</param>
/// <param name="spawns">List of entities to spawn.</param>
/// <param name="spawnDelay">Time after which to spawn the entities.</param>
/// <param name="append">If an already active spawner is selected, will add entities to its list. Otherwise, will fail.</param>
public void StartHordeSpawn(EntityUid uid, List<EntProtoId> spawns, TimeSpan spawnDelay, bool append = true)
{
if (TryComp<VentHordeSpawnerComponent>(uid, out var hordeSpawner))
{
if (append)
{
hordeSpawner.Entities.AddRange(spawns);
}
return;
}
hordeSpawner = EnsureComp<VentHordeSpawnerComponent>(uid);
hordeSpawner.AudioStream = _audio.PlayPvs(hordeSpawner.PassiveSound, uid, hordeSpawner.PassiveSound.Params.WithLoop(true))?.Entity;
hordeSpawner.Entities = spawns;
hordeSpawner.SpawnTime = _timing.CurTime + spawnDelay;
}
/// <summary>
/// Ends a horde spawn, causing all entities to spawn at once.
/// </summary>
/// <param name="entity">The horde spawner entity.</param>
public void EndHordeSpawn(Entity<VentHordeSpawnerComponent> 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<VentHordeSpawnerComponent>(entity);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<VentHordeSpawnerComponent>();
while (query.MoveNext(out var uid, out var comp))
{
if (comp.SpawnTime != null && _timing.CurTime > comp.SpawnTime)
{
EndHordeSpawn((uid, comp));
}
}
}
}

View File

@ -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.

View File

@ -20,11 +20,12 @@
- id: MouseMigration
- id: PowerGridCheck
#- id: RandomSentience # DeltaV - replaced with RandomSentienceGlimmer
- id: SlimesSpawnHorde
- id: SlimesSpawn
- id: SolarFlare
- id: SnakeSpawn
- id: SpiderClownSpawn
- id: SpiderSpawn
- id: SnakeSpawnHorde
- id: SpiderClownSpawnHorde
- id: SpiderSpawnHorde
- id: VentClog
- type: entityTable
@ -502,13 +503,15 @@
- type: PrecognitionResult # DeltaV - Precogniton
message: psionic-power-precognition-vent-clog-result-message
- type: VentClogRule
# Slime Spawn
# 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/_DV/Announcements/attention.ogg # DeltaV - custom announcer
earliestStart: 20
@ -517,19 +520,20 @@
duration: 30 # DeltaV: was 60, used as a delay now
- type: PrecognitionResult # DeltaV - Precogniton
message: psionic-power-precognition-slimes-spawn-result-message
- type: VentCrittersRule
table: !type:GroupSelector # DeltaV: EntityTable instead of spawn entries
- type: VentHordeRule
table: !type:GroupSelector
rolls: 4, 8
children:
- id: MobAdultSlimesBlueAngry
- id: MobAdultSlimesGreenAngry
- id: MobAdultSlimesYellowAngry
# Snake Spawns
- 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/_DV/Announcements/attention.ogg # DeltaV - custom announcer
earliestStart: 20
@ -538,19 +542,20 @@
duration: 30 # DeltaV: was 60, used as a delay now
- type: PrecognitionResult # DeltaV - Precogniton
message: psionic-power-precognition-snake-spawn-result-message
- type: VentCrittersRule
table: !type:GroupSelector # DeltaV: EntityTable instead of spawn entries
- type: VentHordeRule
table: !type:GroupSelector
rolls: 4, 8
children:
- id: MobPurpleSnake
- id: MobSmallPurpleSnake
- id: MobCobraSpace
# Spider Spawns
- 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/_DV/Announcements/attention.ogg # DeltaV - custom announcer
earliestStart: 20
@ -559,16 +564,18 @@
duration: 30 # DeltaV: was 60, used as a delay now
- type: PrecognitionResult # DeltaV - Precogniton
message: psionic-power-precognition-spider-spawn-result-message
- type: VentCrittersRule
table: # DeltaV: EntityTable instead of spawn entries
id: MobGiantSpiderAngry
# Clown Spider Spawns
- 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/_DV/Announcements/attention.ogg # DeltaV - custom announcer
earliestStart: 45 # DeltaV - was 20
@ -577,11 +584,12 @@
duration: 30 # DeltaV: was 60, used as a delay now
- type: PrecognitionResult # DeltaV - Precogniton
message: psionic-power-precognition-spider-clown-spawn-result-message
- type: VentCrittersRule
playerRatio: 35 # DeltaV: Clown spiders are very robust
table: # DeltaV: EntityTable instead of spawn entries
id: MobClownSpider
# Zombie outbreak
- type: VentHordeRule
table: !type:GroupSelector
rolls: 4, 8
children:
- id: MobClownSpider
- type: entity
id: ZombieOutbreak
parent: BaseGameRule

View File

@ -7,6 +7,7 @@
- id: SnailMigrationLowPop
- id: CockroachMigration
- id: MouseMigration
- id: RandomCritterHorde
- id: PitbullMigration # DeltaV - Ventbull event
- type: entityTable
@ -151,5 +152,39 @@
- id: MobSnailMoth
weight: 0.07 # DeltaV - was 0.08
#- id: MobSnailInstantDeath # DeltaV - no
#- id: MobCorticalBorer # DeltaV - ported Borer
# weight: 0.02 # DeltaV - 2% chance on snails
# - id: MobCorticalBorer # DeltaV - ported Borer
# weight: 0.02 # DeltaV - 2% chance on 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