diff --git a/Content.Client/GameObjects/Components/StationEvents/RadiationPulseComponent.cs b/Content.Client/GameObjects/Components/StationEvents/RadiationPulseComponent.cs new file mode 100644 index 0000000000..23d57a957c --- /dev/null +++ b/Content.Client/GameObjects/Components/StationEvents/RadiationPulseComponent.cs @@ -0,0 +1,24 @@ +#nullable enable +using System; +using Content.Shared.GameObjects.Components; +using Robust.Shared.GameObjects; + +namespace Content.Client.GameObjects.Components.StationEvents +{ + [RegisterComponent] + public sealed class RadiationPulseComponent : SharedRadiationPulseComponent + { + public TimeSpan EndTime { get; private set; } + + public override void HandleComponentState(ComponentState? curState, ComponentState? nextState) + { + base.HandleComponentState(curState, nextState); + if (!(curState is RadiationPulseMessage state)) + { + return; + } + + EndTime = state.EndTime; + } + } +} \ No newline at end of file diff --git a/Content.Client/StationEvents/RadiationPulseOverlay.cs b/Content.Client/StationEvents/RadiationPulseOverlay.cs new file mode 100644 index 0000000000..2604419ed5 --- /dev/null +++ b/Content.Client/StationEvents/RadiationPulseOverlay.cs @@ -0,0 +1,152 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Content.Client.GameObjects.Components.StationEvents; +using Content.Shared.GameObjects.Components.Mobs; +using JetBrains.Annotations; +using Robust.Client.Graphics.Drawing; +using Robust.Client.Graphics.Overlays; +using Robust.Client.Interfaces.Graphics.ClientEye; +using Robust.Client.Player; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Color = Robust.Shared.Maths.Color; + +namespace Content.Client.StationEvents +{ + [UsedImplicitly] + public sealed class RadiationPulseOverlay : Overlay + { + [Dependency] private readonly IComponentManager _componentManager = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly IMapManager _mapManager = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly IEyeManager _eyeManager = default!; + + /// + /// Current color of a pulse + /// + private readonly Dictionary _colors = new Dictionary(); + + /// + /// Whether our alpha is increasing or decreasing and at what time does it flip (or stop) + /// + private readonly Dictionary _transitions = + new Dictionary(); + + /// + /// How much the alpha changes per second for each pulse + /// + private readonly Dictionary _alphaRateOfChange = new Dictionary(); + + private TimeSpan _lastTick; + + // TODO: When worldHandle can do DrawCircle change this. + public override OverlaySpace Space => OverlaySpace.ScreenSpace; + + public RadiationPulseOverlay() : base(nameof(SharedOverlayID.RadiationPulseOverlay)) + { + IoCManager.InjectDependencies(this); + _lastTick = _gameTiming.CurTime; + } + + /// + /// Get the current color for the entity, + /// accounting for what its alpha should be and whether it should be transitioning in or out + /// + /// + /// frametime + /// + /// + private Color GetColor(IEntity entity, float elapsedTime, TimeSpan endTime) + { + var currentTime = _gameTiming.CurTime; + + // New pulse + if (!_colors.ContainsKey(entity)) + { + UpdateTransition(entity, currentTime, endTime); + } + + var currentColor = _colors[entity]; + var alphaChange = _alphaRateOfChange[entity] * elapsedTime; + + if (!_transitions[entity].EasingIn) + { + alphaChange *= -1; + } + + if (currentTime > _transitions[entity].TransitionTime) + { + UpdateTransition(entity, currentTime, endTime); + } + + _colors[entity] = _colors[entity].WithAlpha(currentColor.A + alphaChange); + return _colors[entity]; + } + + private void UpdateTransition(IEntity entity, TimeSpan currentTime, TimeSpan endTime) + { + bool easingIn; + TimeSpan transitionTime; + + if (!_transitions.TryGetValue(entity, out var transition)) + { + // Start as false because it will immediately be flipped + easingIn = false; + transitionTime = (endTime - currentTime) / 2 + currentTime; + } + else + { + easingIn = transition.EasingIn; + transitionTime = endTime; + } + + _transitions[entity] = (!easingIn, transitionTime); + _colors[entity] = Color.Green.WithAlpha(0.0f); + _alphaRateOfChange[entity] = 1.0f / (float) (transitionTime - currentTime).TotalSeconds; + } + + protected override void Draw(DrawingHandleBase handle, OverlaySpace currentSpace) + { + // PVS should control the overlay pretty well so the overlay doesn't get instantiated unless we're near one... + var playerEntity = _playerManager.LocalPlayer?.ControlledEntity; + + if (playerEntity == null) + { + return; + } + + var elapsedTime = (float) (_gameTiming.CurTime - _lastTick).TotalSeconds; + _lastTick = _gameTiming.CurTime; + + var radiationPulses = _componentManager + .EntityQuery() + .ToList(); + + var screenHandle = (DrawingHandleScreen) handle; + var viewport = _eyeManager.GetWorldViewport(); + + foreach (var grid in _mapManager.FindGridsIntersecting(playerEntity.Transform.MapID, viewport)) + { + foreach (var pulse in radiationPulses) + { + if (grid.Index != pulse.Owner.Transform.GridID) continue; + + // TODO: Check if viewport intersects circle + var circlePosition = _eyeManager.WorldToScreen(pulse.Owner.Transform.WorldPosition); + var comp = (RadiationPulseComponent) pulse; + + // change to worldhandle when implemented + screenHandle.DrawCircle( + circlePosition, + comp.Range * 64, + GetColor(pulse.Owner, elapsedTime, comp.EndTime)); + } + } + } + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Tests/StationEvents/StationEventsSystemTest.cs b/Content.IntegrationTests/Tests/StationEvents/StationEventsSystemTest.cs new file mode 100644 index 0000000000..2e115930ed --- /dev/null +++ b/Content.IntegrationTests/Tests/StationEvents/StationEventsSystemTest.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Content.Server.GameObjects.EntitySystems; +using Content.Server.GameObjects.EntitySystems.StationEvents; +using NUnit.Framework; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; + +namespace Content.IntegrationTests.Tests.StationEvents +{ + [TestFixture] + public class StationEventsSystemTest : ContentIntegrationTest + { + [Test] + public async Task Test() + { + var server = StartServerDummyTicker(); + + server.Assert(() => + { + // Idle each event once + var stationEventsSystem = EntitySystem.Get(); + var dummyFrameTime = (float) IoCManager.Resolve().TickPeriod.TotalSeconds; + + foreach (var stationEvent in stationEventsSystem.StationEvents) + { + stationEvent.Startup(); + stationEvent.Update(dummyFrameTime); + stationEvent.Shutdown(); + Assert.That(stationEvent.Occurrences == 1); + } + + stationEventsSystem.ResettingCleanup(); + + foreach (var stationEvent in stationEventsSystem.StationEvents) + { + Assert.That(stationEvent.Occurrences == 0); + } + }); + + await server.WaitIdleAsync(); + } + } +} \ No newline at end of file diff --git a/Content.Server/Chat/ChatManager.cs b/Content.Server/Chat/ChatManager.cs index bb33c2c6ca..1a035fbb5c 100644 --- a/Content.Server/Chat/ChatManager.cs +++ b/Content.Server/Chat/ChatManager.cs @@ -44,6 +44,15 @@ namespace Content.Server.Chat _netManager.ServerSendToAll(msg); } + public void DispatchStationAnnouncement(string message) + { + var msg = _netManager.CreateNetMessage(); + msg.Channel = ChatChannel.Radio; + msg.Message = message; + msg.MessageWrap = "Station: {0}"; + _netManager.ServerSendToAll(msg); + } + public void DispatchServerMessage(IPlayerSession player, string message) { var msg = _netManager.CreateNetMessage(); diff --git a/Content.Server/GameObjects/Components/StationEvents/RadiationPulseComponent.cs b/Content.Server/GameObjects/Components/StationEvents/RadiationPulseComponent.cs new file mode 100644 index 0000000000..c0fd604f92 --- /dev/null +++ b/Content.Server/GameObjects/Components/StationEvents/RadiationPulseComponent.cs @@ -0,0 +1,62 @@ +using System; +using Content.Shared.GameObjects.Components; +using Robust.Server.GameObjects.EntitySystems; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.Random; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Random; +using Robust.Shared.Serialization; +using Robust.Shared.Timers; + +namespace Content.Server.GameObjects.Components.StationEvents +{ + [RegisterComponent] + public sealed class RadiationPulseComponent : SharedRadiationPulseComponent + { + private const float MinPulseLifespan = 0.8f; + private const float MaxPulseLifespan = 2.5f; + + public float DPS => _dps; + private float _dps; + + private TimeSpan _endTime; + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + serializer.DataField(ref _dps, "dps", 40.0f); + } + + public override void Initialize() + { + base.Initialize(); + + var currentTime = IoCManager.Resolve().CurTime; + var duration = + TimeSpan.FromSeconds( + IoCManager.Resolve().NextFloat() * (MaxPulseLifespan - MinPulseLifespan) + + MinPulseLifespan); + + _endTime = currentTime + duration; + + Timer.Spawn(duration, + () => + { + if (!Owner.Deleted) + { + Owner.Delete(); + } + }); + + EntitySystem.Get().PlayAtCoords("/Audio/Weapons/Guns/Gunshots/laser3.ogg", Owner.Transform.GridPosition); + Dirty(); + } + + public override ComponentState GetComponentState() + { + return new RadiationPulseMessage(_endTime); + } + } +} \ No newline at end of file diff --git a/Content.Server/GameObjects/EntitySystems/StationEvents/RadiationPulseSystem.cs b/Content.Server/GameObjects/EntitySystems/StationEvents/RadiationPulseSystem.cs new file mode 100644 index 0000000000..2e48bf7120 --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/StationEvents/RadiationPulseSystem.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using Content.Server.GameObjects.Components.Damage; +using Content.Server.GameObjects.Components.Mobs; +using Content.Server.GameObjects.Components.StationEvents; +using Content.Shared.GameObjects; +using Content.Shared.GameObjects.Components.Damage; +using JetBrains.Annotations; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.IoC; + +namespace Content.Server.GameObjects.EntitySystems.StationEvents +{ + [UsedImplicitly] + public sealed class RadiationPulseSystem : EntitySystem + { + // Rather than stuffing around with collidables and checking entities on initialize etc. we'll just tick over + // for each entity in range. Seemed easier than checking entities on spawn, then checking collidables, etc. + // Especially considering each pulse is a big chonker, + no circle hitboxes yet. + + private TypeEntityQuery _speciesQuery; + + /// + /// Damage works with ints so we'll just accumulate damage and once we hit this threshold we'll apply it. + /// + /// This also server to stop spamming the damagethreshold with 1 damage continuously. + private const int DamageThreshold = 10; + + private Dictionary _accumulatedDamage = new Dictionary(); + + public override void Initialize() + { + base.Initialize(); + _speciesQuery = new TypeEntityQuery(typeof(SpeciesComponent)); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + var anyPulses = false; + + foreach (var comp in ComponentManager.EntityQuery()) + { + anyPulses = true; + + foreach (var species in EntityManager.GetEntities(_speciesQuery)) + { + // Work out if we're in range and accumulate more damage + // If we've hit the DamageThreshold we'll also apply that damage to the mob + // If we're really lagging server can apply multiples of the DamageThreshold at once + if (species.Transform.MapID != comp.Owner.Transform.MapID) continue; + + if ((species.Transform.WorldPosition - comp.Owner.Transform.WorldPosition).Length > comp.Range) + { + continue; + } + + var totalDamage = frameTime * comp.DPS; + + if (!_accumulatedDamage.TryGetValue(species, out var accumulatedSpecies)) + { + _accumulatedDamage[species] = 0.0f; + } + + totalDamage += accumulatedSpecies; + _accumulatedDamage[species] = totalDamage; + + if (totalDamage < DamageThreshold) continue; + if (!species.TryGetComponent(out DamageableComponent damageableComponent)) continue; + + var damageMultiple = (int) (totalDamage / DamageThreshold); + _accumulatedDamage[species] = totalDamage % DamageThreshold; + + damageableComponent.TakeDamage(DamageType.Heat, damageMultiple * DamageThreshold, comp.Owner, comp.Owner); + } + } + + if (anyPulses) + { + return; + } + + // probably don't need to worry about clearing this at roundreset unless you have a radiation pulse at roundstart + // (which is currently not possible) + _accumulatedDamage.Clear(); + } + } +} \ No newline at end of file diff --git a/Content.Server/GameObjects/EntitySystems/StationEvents/StationEventSystem.cs b/Content.Server/GameObjects/EntitySystems/StationEvents/StationEventSystem.cs new file mode 100644 index 0000000000..1885b32231 --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/StationEvents/StationEventSystem.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Content.Server.StationEvents; +using JetBrains.Annotations; +using Robust.Server.Interfaces.Player; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.Random; +using Robust.Shared.Interfaces.Reflection; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Localization; + +namespace Content.Server.GameObjects.EntitySystems.StationEvents +{ + [UsedImplicitly] + public sealed class StationEventSystem : EntitySystem + { + // Somewhat based off of TG's implementation of events + + public StationEvent CurrentEvent { get; private set; } + + public IReadOnlyCollection StationEvents => _stationEvents; + private List _stationEvents = new List(); + + private const float MinimumTimeUntilFirstEvent = 600; + + /// + /// How long until the next check for an event runs + /// + /// Default value is how long until first event is allowed + private float _timeUntilNextEvent = MinimumTimeUntilFirstEvent; + + /// + /// Whether random events can run + /// + /// If disabled while an event is running (even if admin run) it will disable it + public bool Enabled + { + get => _enabled; + set + { + if (_enabled == value) + { + return; + } + + _enabled = value; + CurrentEvent?.Shutdown(); + CurrentEvent = null; + } + } + + private bool _enabled = true; + + /// + /// Admins can get a list of all events available to run, regardless of whether their requirements have been met + /// + /// + public string GetEventNames() + { + StringBuilder result = new StringBuilder(); + + foreach (var stationEvent in _stationEvents) + { + result.Append(stationEvent.Name + "\n"); + } + + return result.ToString(); + } + + /// + /// Admins can forcibly run events by passing in the Name + /// + /// The exact string for Name, without localization + /// + public string RunEvent(string name) + { + // Could use a dictionary but it's such a minor thing, eh. + // Wasn't sure on whether to localize this given it's a command + var upperName = name.ToUpperInvariant(); + + foreach (var stationEvent in _stationEvents) + { + if (stationEvent.Name.ToUpperInvariant() != upperName) + { + continue; + } + + CurrentEvent?.Shutdown(); + CurrentEvent = stationEvent; + stationEvent.Startup(); + return Loc.GetString("Running event ") + stationEvent.Name; + } + + // I had string interpolation but lord it made it hard to read + return Loc.GetString("No event named ") + name; + } + + /// + /// Randomly run a valid event immediately, ignoring earlieststart + /// + /// + public string RunRandomEvent() + { + var availableEvents = AvailableEvents(true); + var randomEvent = FindEvent(availableEvents); + + if (randomEvent == null) + { + return Loc.GetString("No valid events available"); + } + + CurrentEvent?.Shutdown(); + CurrentEvent = randomEvent; + CurrentEvent.Startup(); + + return Loc.GetString("Running ") + randomEvent.Name; + } + + /// + /// Admins can stop the currently running event (if applicable) and reset the timer + /// + /// + public string StopEvent() + { + string resultText; + + if (CurrentEvent == null) + { + resultText = Loc.GetString("No event running currently"); + } + else + { + resultText = Loc.GetString("Stopped event ") + CurrentEvent.Name; + CurrentEvent.Shutdown(); + CurrentEvent = null; + } + + ResetTimer(); + return resultText; + } + + public override void Initialize() + { + base.Initialize(); + var reflectionManager = IoCManager.Resolve(); + var typeFactory = IoCManager.Resolve(); + + foreach (var type in reflectionManager.GetAllChildren(typeof(StationEvent))) + { + if (type.IsAbstract) continue; + + var stationEvent = (StationEvent) typeFactory.CreateInstance(type); + _stationEvents.Add(stationEvent); + } + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + if (!Enabled) + { + return; + } + + // Keep running the current event + if (CurrentEvent != null) + { + CurrentEvent.Update(frameTime); + + // Shutdown the event and set the timer for the next event + if (!CurrentEvent.Running) + { + CurrentEvent.Shutdown(); + CurrentEvent = null; + ResetTimer(); + } + + return; + } + + if (_timeUntilNextEvent > 0) + { + _timeUntilNextEvent -= frameTime; + return; + } + + // No point hammering this trying to find events if none are available + var stationEvent = FindEvent(AvailableEvents()); + if (stationEvent == null) + { + ResetTimer(); + } + else + { + CurrentEvent = stationEvent; + } + } + + /// + /// Reset the event timer once the event is done. + /// + private void ResetTimer() + { + var robustRandom = IoCManager.Resolve(); + // 5 - 15 minutes. TG does 3-10 but that's pretty frequent + _timeUntilNextEvent = robustRandom.Next(300, 900); + } + + /// + /// Pick a random event from the available events at this time, also considering their weightings. + /// + /// + private StationEvent FindEvent(List availableEvents) + { + if (availableEvents.Count == 0) + { + return null; + } + + var sumOfWeights = 0; + + foreach (var stationEvent in availableEvents) + { + sumOfWeights += (int) stationEvent.Weight; + } + + var robustRandom = IoCManager.Resolve(); + sumOfWeights = robustRandom.Next(sumOfWeights); + + foreach (var stationEvent in availableEvents) + { + sumOfWeights -= (int) stationEvent.Weight; + + if (sumOfWeights <= 0) + { + return stationEvent; + } + } + + return null; + } + + /// + /// Gets the events that have met their player count, time-until start, etc. + /// + /// + /// + private List AvailableEvents(bool ignoreEarliestStart = false) + { + TimeSpan currentTime; + var playerCount = IoCManager.Resolve().PlayerCount; + + // playerCount does a lock so we'll just keep the variable here + if (!ignoreEarliestStart) + { + currentTime = IoCManager.Resolve().CurTime; + } + else + { + currentTime = TimeSpan.Zero; + } + + var result = new List(); + + foreach (var stationEvent in _stationEvents) + { + if (CanRun(stationEvent, playerCount, currentTime)) + { + result.Add(stationEvent); + } + } + + return result; + } + + private bool CanRun(StationEvent stationEvent, int playerCount, TimeSpan currentTime) + { + if (stationEvent.MaxOccurrences.HasValue && stationEvent.Occurrences >= stationEvent.MaxOccurrences.Value) + { + return false; + } + + if (playerCount < stationEvent.MinimumPlayers) + { + return false; + } + + if (currentTime != TimeSpan.Zero && currentTime.TotalMinutes < stationEvent.EarliestStart) + { + return false; + } + + return true; + } + + public void ResettingCleanup() + { + if (CurrentEvent != null && CurrentEvent.Running) + { + CurrentEvent.Shutdown(); + CurrentEvent = null; + } + + foreach (var stationEvent in _stationEvents) + { + stationEvent.Occurrences = 0; + } + + _timeUntilNextEvent = MinimumTimeUntilFirstEvent; + } + + public override void Shutdown() + { + base.Shutdown(); + CurrentEvent?.Shutdown(); + } + } +} \ No newline at end of file diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index b02df83377..8c3ed03f1b 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -13,6 +13,7 @@ using Content.Server.GameObjects.Components.PDA; using Content.Server.GameObjects.EntitySystems; using Content.Server.GameObjects.EntitySystems.AI.Pathfinding; using Content.Server.GameObjects.EntitySystems.AI.Pathfinding.Accessible; +using Content.Server.GameObjects.EntitySystems.StationEvents; using Content.Server.GameTicking.GamePresets; using Content.Server.Interfaces; using Content.Server.Interfaces.Chat; @@ -619,15 +620,14 @@ namespace Content.Server.GameTicking _playerJoinLobby(player); } - - // Reset pathing system + EntitySystem.Get().ResettingCleanup(); EntitySystem.Get().ResettingCleanup(); + EntitySystem.Get().ResetLayouts(); + EntitySystem.Get().ResettingCleanup(); _spawnedPositions.Clear(); _manifest.Clear(); - - EntitySystem.Get().ResetLayouts(); } private void _preRoundSetup() diff --git a/Content.Server/Interfaces/Chat/IChatManager.cs b/Content.Server/Interfaces/Chat/IChatManager.cs index c3c9b6ceb3..e32c9bea8d 100644 --- a/Content.Server/Interfaces/Chat/IChatManager.cs +++ b/Content.Server/Interfaces/Chat/IChatManager.cs @@ -12,6 +12,12 @@ namespace Content.Server.Interfaces.Chat /// void DispatchServerAnnouncement(string message); + /// + /// Station announcement to every player + /// + /// + void DispatchStationAnnouncement(string message); + void DispatchServerMessage(IPlayerSession player, string message); void EntitySay(IEntity source, string message); diff --git a/Content.Server/StationEvents/PowerGridCheck.cs b/Content.Server/StationEvents/PowerGridCheck.cs new file mode 100644 index 0000000000..882fa61f7a --- /dev/null +++ b/Content.Server/StationEvents/PowerGridCheck.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using Content.Server.GameObjects.Components.Power; +using Content.Server.GameObjects.Components.Power.ApcNetComponents; +using Content.Server.GameObjects.Components.Power.PowerNetComponents; +using JetBrains.Annotations; +using Robust.Server.GameObjects.EntitySystems; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Random; +using Robust.Shared.IoC; +using Robust.Shared.Localization; + +namespace Content.Server.StationEvents +{ + [UsedImplicitly] + public sealed class PowerGridCheck : StationEvent + { + public override string Name => "PowerGridCheck"; + + public override StationEventWeight Weight => StationEventWeight.Normal; + + public override int? MaxOccurrences => 3; + + protected override string StartAnnouncement => Loc.GetString( + "Abnormal activity detected in the station's powernet. As a precautionary measure, the station's power will be shut off for an indeterminate duration."); + + protected override string EndAnnouncement => Loc.GetString( + "Power has been restored to the station. We apologize for the inconvenience."); + + private float _elapsedTime; + private int _failDuration; + + private Dictionary _powered = new Dictionary(); + + private readonly List _toPowerDown = new List(); + + public override void Startup() + { + base.Startup(); + EntitySystem.Get().PlayGlobal("/Audio/Announcements/power_off.ogg"); + + _elapsedTime = 0.0f; + _failDuration = IoCManager.Resolve().Next(30, 120); + var componentManager = IoCManager.Resolve(); + + foreach (var component in componentManager.EntityQuery()) + { + component.PowerDisabled = true; + } + } + + public override void Shutdown() + { + base.Shutdown(); + EntitySystem.Get().PlayGlobal("/Audio/Announcements/power_on.ogg"); + + foreach (var (entity, powered) in _powered) + { + if (entity.Deleted) continue; + + if (entity.TryGetComponent(out PowerReceiverComponent powerReceiverComponent)) + { + powerReceiverComponent.PowerDisabled = powered; + } + } + + _powered.Clear(); + } + + public override void Update(float frameTime) + { + if (!Running) + { + return; + } + + _elapsedTime += frameTime; + + if (_elapsedTime < _failDuration) + { + return; + } + + Running = false; + } + } +} \ No newline at end of file diff --git a/Content.Server/StationEvents/RadiationStorm.cs b/Content.Server/StationEvents/RadiationStorm.cs new file mode 100644 index 0000000000..9163b097e3 --- /dev/null +++ b/Content.Server/StationEvents/RadiationStorm.cs @@ -0,0 +1,131 @@ +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.GameObjects.Components.Mobs; +using JetBrains.Annotations; +using Robust.Server.GameObjects.EntitySystems; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.Interfaces.Random; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Map; +using Robust.Shared.Random; + +namespace Content.Server.StationEvents +{ + [UsedImplicitly] + public sealed class RadiationStorm : StationEvent + { + // Based on Goonstation style radiation storm with some TG elements (announcer, etc.) + + [Dependency] private IEntityManager _entityManager = default!; + [Dependency] private IMapManager _mapManager = default!; + [Dependency] private IRobustRandom _robustRandom = default!; + + public override string Name => "RadiationStorm"; + + protected override string StartAnnouncement => Loc.GetString( + "High levels of radiation detected near the station. Evacuate any areas containing abnormal green energy fields."); + + protected override string EndAnnouncement => Loc.GetString( + "The radiation threat has passed. Please return to your workplaces."); + + /// + /// How long until the radiation storm starts + /// + private const float StartupTime = 10; + + /// + /// How long the radiation storm has been running for + /// + private float _timeElapsed; + + private int _pulsesRemaining; + private float _timeUntilPulse; + private const float MinPulseDelay = 0.2f; + private const float MaxPulseDelay = 0.8f; + + public override void Startup() + { + base.Startup(); + EntitySystem.Get().PlayGlobal("/Audio/Announcements/radiation.ogg"); + IoCManager.InjectDependencies(this); + + _timeElapsed = 0.0f; + _pulsesRemaining = _robustRandom.Next(30, 100); + + var componentManager = IoCManager.Resolve(); + + foreach (var overlay in componentManager.EntityQuery()) + { + overlay.AddOverlay(SharedOverlayID.RadiationPulseOverlay); + } + } + + public override void Shutdown() + { + base.Shutdown(); + + // IOC uninject? + _entityManager = null; + _mapManager = null; + _robustRandom = null; + + var componentManager = IoCManager.Resolve(); + + foreach (var overlay in componentManager.EntityQuery()) + { + overlay.RemoveOverlay(SharedOverlayID.RadiationPulseOverlay); + } + } + + public override void Update(float frameTime) + { + _timeElapsed += frameTime; + + if (_pulsesRemaining == 0) + { + Running = false; + } + + if (!Running) + { + return; + } + + if (_timeElapsed < StartupTime) + { + return; + } + + _timeUntilPulse -= frameTime; + + if (_timeUntilPulse <= 0.0f) + { + // TODO: Probably rate-limit this for small grids (e.g. no more than 25% covered) + foreach (var grid in _mapManager.GetAllGrids()) + { + if (grid.IsDefaultGrid) continue; + SpawnPulse(grid); + } + } + } + + private void SpawnPulse(IMapGrid mapGrid) + { + _entityManager.SpawnEntity("RadiationPulse", FindRandomGrid(mapGrid)); + _timeUntilPulse = _robustRandom.NextFloat() * (MaxPulseDelay - MinPulseDelay) + MinPulseDelay; + _pulsesRemaining -= 1; + } + + private GridCoordinates FindRandomGrid(IMapGrid mapGrid) + { + // TODO: Need to get valid tiles? (maybe just move right if the tile we chose is invalid?) + + var randomX = _robustRandom.Next((int) mapGrid.WorldBounds.Left, (int) mapGrid.WorldBounds.Right); + var randomY = _robustRandom.Next((int) mapGrid.WorldBounds.Bottom, (int) mapGrid.WorldBounds.Top); + + return mapGrid.GridTileToLocal(new MapIndices(randomX, randomY)); + } + } +} \ No newline at end of file diff --git a/Content.Server/StationEvents/StationEvent.cs b/Content.Server/StationEvents/StationEvent.cs new file mode 100644 index 0000000000..8d351aff8e --- /dev/null +++ b/Content.Server/StationEvents/StationEvent.cs @@ -0,0 +1,94 @@ +using Content.Server.Interfaces.Chat; +using Robust.Server.GameObjects; +using Robust.Shared.IoC; + +namespace Content.Server.StationEvents +{ + public abstract class StationEvent + { + /// + /// If the event has started and is currently running + /// + public bool Running { get; protected set; } + + /// + /// Human-readable name for the event + /// + public abstract string Name { get; } + + public virtual StationEventWeight Weight { get; } = StationEventWeight.Normal; + + /// + /// What should be said in chat when the event starts (if anything). + /// + protected virtual string StartAnnouncement { get; } = null; + + /// + /// What should be said in chat when the event end (if anything). + /// + protected virtual string EndAnnouncement { get; } = null; + + /// + /// In minutes, when is the first time this event can start + /// + /// + public virtual int EarliestStart { get; } = 20; + + /// + /// How many players need to be present on station for the event to run + /// + /// To avoid running deadly events with low-pop + public virtual int MinimumPlayers { get; } = 0; + + /// + /// How many times this event has run this round + /// + public int Occurrences { get; set; } = 0; + + /// + /// How many times this even can occur in a single round + /// + public virtual int? MaxOccurrences { get; } = null; + + /// + /// Called once when the station event starts + /// + public virtual void Startup() + { + Running = true; + Occurrences += 1; + if (StartAnnouncement != null) + { + var chatManager = IoCManager.Resolve(); + chatManager.DispatchStationAnnouncement(StartAnnouncement); + } + } + + /// + /// Called every tick when this event is active + /// + /// + public abstract void Update(float frameTime); + + /// + /// Called once when the station event ends + /// + public virtual void Shutdown() + { + if (EndAnnouncement != null) + { + var chatManager = IoCManager.Resolve(); + chatManager.DispatchStationAnnouncement(EndAnnouncement); + } + } + } + + public enum StationEventWeight + { + VeryLow = 0, + Low = 5, + Normal = 10, + High = 15, + VeryHigh = 20, + } +} \ No newline at end of file diff --git a/Content.Server/StationEvents/StationEventCommand.cs b/Content.Server/StationEvents/StationEventCommand.cs new file mode 100644 index 0000000000..39331c6d5b --- /dev/null +++ b/Content.Server/StationEvents/StationEventCommand.cs @@ -0,0 +1,102 @@ +#nullable enable +using Content.Server.GameObjects.EntitySystems; +using Content.Server.GameObjects.EntitySystems.StationEvents; +using JetBrains.Annotations; +using Robust.Server.Interfaces.Console; +using Robust.Server.Interfaces.Player; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Localization; + +namespace Content.Client.Commands +{ + [UsedImplicitly] + public sealed class StationEventCommand : IClientCommand + { + public string Command => "events"; + public string Description => "Provides admin control to station events"; + public string Help => "events >\n" + + "list: return all event names that can be run\n " + + "pause: stop all random events from running\n" + + "resume: allow random events to run again\n" + + "random: choose a random event that is valid and run it\n" + + "run: start a particular event now; is case-insensitive and not localized"; + public void Execute(IConsoleShell shell, IPlayerSession? player, string[] args) + { + if (args.Length == 0) + { + shell.SendText(player, "Need more args"); + return; + } + + if (args[0] == "list") + { + var resultText = "Random\n" + EntitySystem.Get().GetEventNames(); + shell.SendText(player, resultText); + return; + } + + // Didn't use a "toggle" so it's explicit + if (args[0] == "pause") + { + var stationEventSystem = EntitySystem.Get(); + + if (!stationEventSystem.Enabled) + { + shell.SendText(player, Loc.GetString("Station events are already paused")); + return; + } + else + { + stationEventSystem.Enabled = false; + shell.SendText(player, Loc.GetString("Station events paused")); + return; + } + } + + if (args[0] == "resume") + { + var stationEventSystem = EntitySystem.Get(); + + if (stationEventSystem.Enabled) + { + shell.SendText(player, Loc.GetString("Station events are already running")); + return; + } + else + { + stationEventSystem.Enabled = true; + shell.SendText(player, Loc.GetString("Station events resumed")); + return; + } + } + + if (args[0] == "stop") + { + var resultText = EntitySystem.Get().StopEvent(); + shell.SendText(player, resultText); + return; + } + + if (args[0] == "run" && args.Length == 2) + { + var eventName = args[1]; + string resultText; + + if (eventName == "random") + { + resultText = EntitySystem.Get().RunRandomEvent(); + } + else + { + resultText = EntitySystem.Get().RunEvent(eventName); + } + + shell.SendText(player, resultText); + return; + } + + shell.SendText(player, Loc.GetString("Invalid events command")); + return; + } + } +} \ No newline at end of file diff --git a/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs b/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs index 865b3092e8..3ee301ab96 100644 --- a/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs +++ b/Content.Shared/GameObjects/Components/Mobs/SharedOverlayEffectsComponent.cs @@ -114,6 +114,7 @@ namespace Content.Shared.GameObjects.Components.Mobs { GradientCircleMaskOverlay, CircleMaskOverlay, - FlashOverlay + FlashOverlay, + RadiationPulseOverlay, } } diff --git a/Content.Shared/GameObjects/Components/SharedRadiationStorm.cs b/Content.Shared/GameObjects/Components/SharedRadiationStorm.cs new file mode 100644 index 0000000000..efa28f0919 --- /dev/null +++ b/Content.Shared/GameObjects/Components/SharedRadiationStorm.cs @@ -0,0 +1,38 @@ +using System; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization; + +namespace Content.Shared.GameObjects.Components +{ + public abstract class SharedRadiationPulseComponent : Component + { + public override string Name => "RadiationPulse"; + public override uint? NetID => ContentNetIDs.RADIATION_PULSE; + + /// + /// Radius of the pulse from its position + /// + public float Range => _range; + private float _range; + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + serializer.DataField(ref _range, "range", 5.0f); + } + } + + /// + /// For syncing the pulse's lifespan between client and server for the overlay + /// + [Serializable, NetSerializable] + public sealed class RadiationPulseMessage : ComponentState + { + public TimeSpan EndTime { get; } + + public RadiationPulseMessage(TimeSpan endTime) : base(ContentNetIDs.RADIATION_PULSE) + { + EndTime = endTime; + } + } +} \ No newline at end of file diff --git a/Content.Shared/GameObjects/ContentNetIDs.cs b/Content.Shared/GameObjects/ContentNetIDs.cs index 6a38d3e669..9ecd4a918b 100644 --- a/Content.Shared/GameObjects/ContentNetIDs.cs +++ b/Content.Shared/GameObjects/ContentNetIDs.cs @@ -63,6 +63,7 @@ public const uint DISPOSABLE = 1056; public const uint GAS_ANALYZER = 1057; public const uint DO_AFTER = 1058; + public const uint RADIATION_PULSE = 1059; // Net IDs for integration tests. public const uint PREDICTION_TEST = 10001; diff --git a/Resources/Audio/Announcements/power_off.ogg b/Resources/Audio/Announcements/power_off.ogg new file mode 100644 index 0000000000..7dcd968c73 Binary files /dev/null and b/Resources/Audio/Announcements/power_off.ogg differ diff --git a/Resources/Audio/Announcements/power_on.ogg b/Resources/Audio/Announcements/power_on.ogg new file mode 100644 index 0000000000..ca641eafe0 Binary files /dev/null and b/Resources/Audio/Announcements/power_on.ogg differ diff --git a/Resources/Audio/Announcements/radiation.ogg b/Resources/Audio/Announcements/radiation.ogg new file mode 100644 index 0000000000..610f6cd8c0 Binary files /dev/null and b/Resources/Audio/Announcements/radiation.ogg differ diff --git a/Resources/Groups/groups.yml b/Resources/Groups/groups.yml index ce1885473a..1cf921b5f8 100644 --- a/Resources/Groups/groups.yml +++ b/Resources/Groups/groups.yml @@ -36,6 +36,7 @@ - listplayers - loc - hostlogin + - events - Index: 100 Name: Administrator @@ -93,6 +94,7 @@ - unanchor - tubeconnections - tilewalls + - events CanViewVar: true CanAdminPlace: true @@ -181,6 +183,7 @@ - settemp - setatmostemp - tilewalls + - events CanViewVar: true CanAdminPlace: true CanScript: true diff --git a/Resources/Prototypes/Entities/radiation.yml b/Resources/Prototypes/Entities/radiation.yml new file mode 100644 index 0000000000..81d20ca718 --- /dev/null +++ b/Resources/Prototypes/Entities/radiation.yml @@ -0,0 +1,7 @@ +- type: entity + name: shimmering anomaly + id: RadiationPulse + abstract: true + description: Looking at this anomaly makes you feel strange, like something is pushing at your eyes. + components: + - type: RadiationPulse \ No newline at end of file