From f3bb1becf5dce787367f52e542a847b2248f0304 Mon Sep 17 00:00:00 2001 From: ScarKy0 <106310278+ScarKy0@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:28:47 +0100 Subject: [PATCH] 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 * review + powercreep * lower the times to account for the gamerule delay * review * review * fuck this return actually * fial --------- Co-authored-by: SnappingOpossum --- .../Components/VentHordeRuleComponent.cs | 26 ++++ .../StationEvents/Events/VentCrittersRule.cs | 3 + .../StationEvents/Events/VentHordeRule.cs | 122 ++++++++++++++++++ .../Components/VentHordeSpawnerComponent.cs | 77 +++++++++++ .../VentHorde/Systems/VentHordeSystem.cs | 119 +++++++++++++++++ .../station-events/events/vent-critters.ftl | 2 +- Resources/Prototypes/GameRules/events.yml | 86 ++++++------ Resources/Prototypes/GameRules/pests.yml | 54 +++++--- 8 files changed, 431 insertions(+), 58 deletions(-) create mode 100644 Content.Server/StationEvents/Components/VentHordeRuleComponent.cs create mode 100644 Content.Server/StationEvents/Events/VentHordeRule.cs create mode 100644 Content.Server/VentHorde/Components/VentHordeSpawnerComponent.cs create mode 100644 Content.Server/VentHorde/Systems/VentHordeSystem.cs 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