diff --git a/Content.Client/Zombies/ZombieSystem.cs b/Content.Client/Zombies/ZombieSystem.cs index eb45d86003..8816735a2a 100644 --- a/Content.Client/Zombies/ZombieSystem.cs +++ b/Content.Client/Zombies/ZombieSystem.cs @@ -1,8 +1,46 @@ -using Content.Shared.Zombies; +using System.Linq; +using Content.Shared.Humanoid; +using Content.Shared.StatusIcon; +using Content.Shared.StatusIcon.Components; +using Content.Shared.Zombies; +using Robust.Client.GameObjects; +using Robust.Client.Player; +using Robust.Shared.Prototypes; namespace Content.Client.Zombies; public sealed class ZombieSystem : SharedZombieSystem { + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IPrototypeManager _prototype = default!; + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnStartup); + SubscribeLocalEvent(OnGetStatusIcon); + } + + private void OnStartup(EntityUid uid, ZombieComponent component, ComponentStartup args) + { + if (HasComp(uid)) + return; + + if (!TryComp(uid, out var sprite)) + return; + + for (var i = 0; i < sprite.AllLayers.Count(); i++) + { + sprite.LayerSetColor(i, component.SkinColor); + } + } + + private void OnGetStatusIcon(EntityUid uid, ZombieComponent component, ref GetStatusIconsEvent args) + { + if (!HasComp(_player.LocalPlayer?.ControlledEntity)) + return; + + args.StatusIcons.Add(_prototype.Index(component.ZombieStatusIcon)); + } } diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs index c7e66dc77b..2c668c353c 100644 --- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs +++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs @@ -5,14 +5,13 @@ using Content.Shared.Administration; using Content.Shared.Database; using Content.Shared.Verbs; using Robust.Server.GameObjects; -using Robust.Shared.Configuration; using Robust.Shared.Utility; namespace Content.Server.Administration.Systems; public sealed partial class AdminVerbSystem { - [Dependency] private readonly ZombifyOnDeathSystem _zombify = default!; + [Dependency] private readonly ZombieSystem _zombie = default!; [Dependency] private readonly TraitorRuleSystem _traitorRule = default!; [Dependency] private readonly NukeopsRuleSystem _nukeopsRule = default!; [Dependency] private readonly PiratesRuleSystem _piratesRule = default!; @@ -53,10 +52,10 @@ public sealed partial class AdminVerbSystem { Text = "Make Zombie", Category = VerbCategory.Antag, - Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "bio"), + Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/Actions/zombie-turn.png")), Act = () => { - _zombify.ZombifyEntity(args.Target); + _zombie.ZombifyEntity(args.Target); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-zombie"), diff --git a/Content.Server/Chemistry/ReagentEffects/CureZombieInfection.cs b/Content.Server/Chemistry/ReagentEffects/CureZombieInfection.cs index dceecaf9bf..638830c046 100644 --- a/Content.Server/Chemistry/ReagentEffects/CureZombieInfection.cs +++ b/Content.Server/Chemistry/ReagentEffects/CureZombieInfection.cs @@ -9,24 +9,28 @@ namespace Content.Server.Chemistry.ReagentEffects; public sealed class CureZombieInfection : ReagentEffect { [DataField("innoculate")] - public bool innoculate = false; + public bool Innoculate; protected override string? ReagentEffectGuidebookText(IPrototypeManager prototype, IEntitySystemManager entSys) { - if(innoculate == true) - return Loc.GetString("reagent-effect-guidebook-innoculate-zombie-infection", ("chance", Probability)); - - return Loc.GetString("reagent-effect-guidebook-cure-zombie-infection", ("chance", Probability)); + if(Innoculate) + return Loc.GetString("reagent-effect-guidebook-innoculate-zombie-infection", ("chance", Probability)); + + return Loc.GetString("reagent-effect-guidebook-cure-zombie-infection", ("chance", Probability)); } // Removes the Zombie Infection Components public override void Effect(ReagentEffectArgs args) { var entityManager = args.EntityManager; + if (entityManager.HasComponent(args.SolutionEntity)) + return; + entityManager.RemoveComponent(args.SolutionEntity); entityManager.RemoveComponent(args.SolutionEntity); - if (innoculate == true) { + if (Innoculate) + { entityManager.EnsureComponent(args.SolutionEntity); } } diff --git a/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs index 869765c14f..84b440d69e 100644 --- a/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs +++ b/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs @@ -1,35 +1,97 @@ -namespace Content.Server.GameTicking.Rules.Components; +using Content.Shared.Roles; +using Robust.Shared.Audio; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +namespace Content.Server.GameTicking.Rules.Components; [RegisterComponent, Access(typeof(ZombieRuleSystem))] public sealed class ZombieRuleComponent : Component { + [DataField("initialInfectedNames")] public Dictionary InitialInfectedNames = new(); - public string PatientZeroPrototypeID = "InitialInfected"; + [DataField("patientZeroPrototypeId", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string PatientZeroPrototypeId = "InitialInfected"; + + /// + /// Whether or not the initial infected have been chosen. + /// + [DataField("infectedChosen")] + public bool InfectedChosen; + + /// + /// When the round will next check for round end. + /// + [DataField("nextRoundEndCheck", customTypeSerializer: typeof(TimeOffsetSerializer))] + public TimeSpan NextRoundEndCheck; + + /// + /// The amount of time between each check for the end of the round. + /// + [DataField("endCheckDelay")] + public TimeSpan EndCheckDelay = TimeSpan.FromSeconds(30); + + /// + /// The time at which the initial infected will be chosen. + /// + [DataField("startTime", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)] + public TimeSpan? StartTime; + + /// + /// The minimum amount of time after the round starts that the initial infected will be chosen. + /// + [DataField("minStartDelay")] + public TimeSpan MinStartDelay = TimeSpan.FromMinutes(10); + + /// + /// The maximum amount of time after the round starts that the initial infected will be chosen. + /// + [DataField("maxStartDelay")] + public TimeSpan MaxStartDelay = TimeSpan.FromMinutes(15); + + /// + /// The sound that plays when someone becomes an initial infected. + /// todo: this should have a unique sound instead of reusing the zombie one. + /// + [DataField("initialInfectedSound")] + public SoundSpecifier InitialInfectedSound = new SoundPathSpecifier("/Audio/Ambience/Antag/zombie_start.ogg"); + + /// + /// The minimum amount of time initial infected have before they start taking infection damage. + /// + [DataField("minInitialInfectedGrace")] + public TimeSpan MinInitialInfectedGrace = TimeSpan.FromMinutes(12.5f); + + /// + /// The maximum amount of time initial infected have before they start taking damage. + /// + [DataField("maxInitialInfectedGrace")] + public TimeSpan MaxInitialInfectedGrace = TimeSpan.FromMinutes(15f); + + /// + /// How many players for each initial infected. + /// + [DataField("playersPerInfected")] + public int PlayersPerInfected = 10; + + /// + /// The maximum number of initial infected. + /// + [DataField("maxInitialInfected")] + public int MaxInitialInfected = 6; + + /// + /// After this amount of the crew become zombies, the shuttle will be automatically called. + /// + [DataField("zombieShuttleCallPercentage")] + public float ZombieShuttleCallPercentage = 0.5f; + + /// + /// Have we called the evac shuttle yet? + /// + [DataField("shuttleCalled")] + public bool ShuttleCalled; + public const string ZombifySelfActionPrototype = "TurnUndead"; - - /// - /// After this many seconds the players will be forced to turn into zombies (at minimum) - /// Defaults to 20 minutes. 20*60 = 1200 seconds. - /// - /// Zombie time for a given player is: - /// random MinZombieForceSecs to MaxZombieForceSecs + up to PlayerZombieForceVariation - /// - [DataField("minZombieForceSecs"), ViewVariables(VVAccess.ReadWrite)] - public float MinZombieForceSecs = 1200; - - /// - /// After this many seconds the players will be forced to turn into zombies (at maximum) - /// Defaults to 30 minutes. 30*60 = 1800 seconds. - /// - [DataField("maxZombieForceSecs"), ViewVariables(VVAccess.ReadWrite)] - public float MaxZombieForceSecs = 1800; - - /// - /// How many additional seconds each player will get (at random) to scatter forced zombies over time. - /// Defaults to 2 minutes. 2*60 = 120 seconds. - /// - [DataField("playerZombieForceVariationSecs"), ViewVariables(VVAccess.ReadWrite)] - public float PlayerZombieForceVariationSecs = 120; } diff --git a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs index 82f5e330fb..904ce00967 100644 --- a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs @@ -29,6 +29,7 @@ using Content.Shared.Mobs.Components; using Content.Shared.Nuke; using Content.Shared.Preferences; using Content.Shared.Roles; +using Content.Shared.Zombies; using Robust.Server.GameObjects; using Robust.Server.Maps; using Robust.Server.Player; @@ -74,6 +75,7 @@ public sealed class NukeopsRuleSystem : GameRuleSystem SubscribeLocalEvent(OnMindAdded); SubscribeLocalEvent(OnComponentInit); SubscribeLocalEvent(OnComponentRemove); + SubscribeLocalEvent(OnOperativeZombified); } private void OnComponentInit(EntityUid uid, NukeOperativeComponent component, ComponentInit args) @@ -100,6 +102,11 @@ public sealed class NukeopsRuleSystem : GameRuleSystem CheckRoundShouldEnd(); } + private void OnOperativeZombified(EntityUid uid, NukeOperativeComponent component, ref EntityZombifiedEvent args) + { + RemCompDeferred(uid, component); + } + private void OnNukeExploded(NukeExplodedEvent ev) { var query = EntityQueryEnumerator(); diff --git a/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs b/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs index 9ee6487993..1b1ce65948 100644 --- a/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Linq; using Content.Server.Actions; using Content.Server.Chat.Managers; +using Content.Server.Chat.Systems; using Content.Server.GameTicking.Rules.Components; using Content.Server.Mind; using Content.Server.Mind.Components; @@ -10,7 +11,8 @@ using Content.Server.Popups; using Content.Server.Preferences.Managers; using Content.Server.Roles; using Content.Server.RoundEnd; -using Content.Server.Traitor; +using Content.Server.Station.Components; +using Content.Server.Station.Systems; using Content.Server.Zombies; using Content.Shared.Actions.ActionTypes; using Content.Shared.CCVar; @@ -21,11 +23,12 @@ using Content.Shared.Mobs.Systems; using Content.Shared.Preferences; using Content.Shared.Roles; using Content.Shared.Zombies; +using Robust.Server.GameObjects; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Prototypes; using Robust.Shared.Random; -using Robust.Shared.Utility; +using Robust.Shared.Timing; namespace Content.Server.GameTicking.Rules; @@ -35,25 +38,25 @@ public sealed class ZombieRuleSystem : GameRuleSystem [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IChatManager _chatManager = default!; + [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IServerPreferencesManager _prefs = default!; - [Dependency] private readonly RoundEndSystem _roundEndSystem = default!; + [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly RoundEndSystem _roundEnd = default!; [Dependency] private readonly PopupSystem _popup = default!; [Dependency] private readonly ActionsSystem _action = default!; [Dependency] private readonly MobStateSystem _mobState = default!; - [Dependency] private readonly ZombifyOnDeathSystem _zombify = default!; + [Dependency] private readonly ZombieSystem _zombie = default!; [Dependency] private readonly MindSystem _mindSystem = default!; + [Dependency] private readonly StationSystem _station = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnStartAttempt); - SubscribeLocalEvent(OnMobStateChanged); SubscribeLocalEvent(OnRoundEndText); - SubscribeLocalEvent(OnJobAssigned); - - SubscribeLocalEvent(OnEntityZombified); SubscribeLocalEvent(OnZombifySelf); } @@ -62,7 +65,7 @@ public sealed class ZombieRuleSystem : GameRuleSystem foreach (var zombie in EntityQuery()) { // This is just the general condition thing used for determining the win/lose text - var fraction = GetInfectedFraction(); + var fraction = GetInfectedFraction(true, true); if (fraction <= 0) ev.AddLine(Loc.GetString("zombie-round-end-amount-none")); @@ -86,80 +89,63 @@ public sealed class ZombieRuleSystem : GameRuleSystem var healthy = GetHealthyHumans(); // Gets a bunch of the living players and displays them if they're under a threshold. // InitialInfected is used for the threshold because it scales with the player count well. - if (healthy.Count > 0 && healthy.Count <= 2 * zombie.InitialInfectedNames.Count) + if (healthy.Count <= 0 || healthy.Count > 2 * zombie.InitialInfectedNames.Count) + continue; + ev.AddLine(""); + ev.AddLine(Loc.GetString("zombie-round-end-survivor-count", ("count", healthy.Count))); + foreach (var survivor in healthy) { - ev.AddLine(""); - ev.AddLine(Loc.GetString("zombie-round-end-survivor-count", ("count", healthy.Count))); - foreach (var survivor in healthy) + var meta = MetaData(survivor); + var username = string.Empty; + if (TryComp(survivor, out var mindcomp)) { - var meta = MetaData(survivor); - var username = string.Empty; - if (TryComp(survivor, out var mindcomp)) - if (mindcomp.Mind != null && mindcomp.Mind.Session != null) - username = mindcomp.Mind.Session.Name; - - ev.AddLine(Loc.GetString("zombie-round-end-user-was-survivor", - ("name", meta.EntityName), - ("username", username))); + if (mindcomp.Mind != null && mindcomp.Mind.Session != null) + username = mindcomp.Mind.Session.Name; } + + ev.AddLine(Loc.GetString("zombie-round-end-user-was-survivor", + ("name", meta.EntityName), + ("username", username))); } } } - private void OnJobAssigned(RulePlayerJobsAssignedEvent ev) - { - var query = EntityQueryEnumerator(); - while (query.MoveNext(out var uid, out var zombies, out var gameRule)) - { - if (!GameTicker.IsGameRuleAdded(uid, gameRule)) - continue; - InfectInitialPlayers(zombies); - } - } - - /// - /// This is just checked if the last human somehow dies - /// by starving or flying off into space. - /// - private void OnMobStateChanged(MobStateChangedEvent ev) - { - CheckRoundEnd(ev.Target); - } - - private void OnEntityZombified(EntityZombifiedEvent ev) - { - CheckRoundEnd(ev.Target); - } - /// /// The big kahoona function for checking if the round is gonna end /// - /// depending on this uid, we should care about the round ending - private void CheckRoundEnd(EntityUid target) + private void CheckRoundEnd() { var query = EntityQueryEnumerator(); - while (query.MoveNext(out var uid, out var zombies, out var gameRule)) + while (query.MoveNext(out var uid, out var comp, out var gameRule)) { - if (GameTicker.IsGameRuleActive(uid, gameRule)) + if (!GameTicker.IsGameRuleActive(uid, gameRule)) continue; - // We only care about players, not monkeys and such. - if (!HasComp(target)) - continue; - - var fraction = GetInfectedFraction(); var healthy = GetHealthyHumans(); if (healthy.Count == 1) // Only one human left. spooky _popup.PopupEntity(Loc.GetString("zombie-alone"), healthy[0], healthy[0]); - if (fraction >= 1) // Oops, all zombies - _roundEndSystem.EndRound(); + + if (!comp.ShuttleCalled && GetInfectedFraction(false) >= comp.ZombieShuttleCallPercentage) + { + comp.ShuttleCalled = true; + foreach (var station in _station.GetStations()) + { + _chat.DispatchStationAnnouncement(station, Loc.GetString("zombie-shuttle-call"), colorOverride: Color.Crimson); + } + _roundEnd.RequestRoundEnd(null, false); + } + + // we include dead for this count because we don't want to end the round + // when everyone gets on the shuttle. + if (GetInfectedFraction() >= 1) // Oops, all zombies + _roundEnd.EndRound(); } } private void OnStartAttempt(RoundStartAttemptEvent ev) { var query = EntityQueryEnumerator(); - while (query.MoveNext(out var uid, out var zombies, out var gameRule)) + while (query.MoveNext(out var uid, out _, out var gameRule)) { if (!GameTicker.IsGameRuleAdded(uid, gameRule)) continue; @@ -185,37 +171,86 @@ public sealed class ZombieRuleSystem : GameRuleSystem protected override void Started(EntityUid uid, ZombieRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args) { base.Started(uid, component, gameRule, args); + component.StartTime = _timing.CurTime + _random.Next(component.MinStartDelay, component.MaxStartDelay); + } + + protected override void ActiveTick(EntityUid uid, ZombieRuleComponent component, GameRuleComponent gameRule, float frameTime) + { + base.ActiveTick(uid, component, gameRule, frameTime); + + if (component.InfectedChosen) + { + if (_timing.CurTime >= component.NextRoundEndCheck) + { + component.NextRoundEndCheck += component.EndCheckDelay; + CheckRoundEnd(); + } + return; + } + + if (component.StartTime == null || _timing.CurTime < component.StartTime) + return; + InfectInitialPlayers(component); } private void OnZombifySelf(EntityUid uid, ZombifyOnDeathComponent component, ZombifySelfActionEvent args) { - _zombify.ZombifyEntity(uid); + _zombie.ZombifyEntity(uid); var action = new InstantAction(_prototypeManager.Index(ZombieRuleComponent.ZombifySelfActionPrototype)); _action.RemoveAction(uid, action); } - private float GetInfectedFraction() + private float GetInfectedFraction(bool includeOffStation = true, bool includeDead = false) { - var players = EntityQuery(true); - var zombers = EntityQuery(true); + var players = GetHealthyHumans(includeOffStation); + var zombieCount = 0; + var query = EntityQueryEnumerator(); + while (query.MoveNext(out _, out _, out _, out var mob)) + { + if (!includeDead && mob.CurrentState == MobState.Dead) + continue; + zombieCount++; + } - return zombers.Count() / (float) players.Count(); + return zombieCount / (float) (players.Count + zombieCount); } - private List GetHealthyHumans() + /// + /// Gets the list of humans who are alive, not zombies, and are on a station. + /// Flying off via a shuttle disqualifies you. + /// + /// + private List GetHealthyHumans(bool includeOffStation = true) { var healthy = new List(); - var players = AllEntityQuery(); - var zombers = GetEntityQuery(); - while (players.MoveNext(out var uid, out _, out var mob)) + + var stationGrids = new HashSet(); + if (!includeOffStation) { - if (_mobState.IsAlive(uid, mob) && !zombers.HasComponent(uid)) + foreach (var station in _station.GetStationsSet()) { - healthy.Add(uid); + if (TryComp(station, out var data) && _station.GetLargestGrid(data) is { } grid) + stationGrids.Add(grid); } } + + var players = AllEntityQuery(); + var zombers = GetEntityQuery(); + while (players.MoveNext(out var uid, out _, out _, out var mob, out var xform)) + { + if (!_mobState.IsAlive(uid, mob)) + continue; + + if (zombers.HasComponent(uid)) + continue; + + if (!includeOffStation && !stationGrids.Contains(xform.GridUid ?? EntityUid.Invalid)) + continue; + + healthy.Add(uid); + } return healthy; } @@ -230,95 +265,80 @@ public sealed class ZombieRuleSystem : GameRuleSystem /// private void InfectInitialPlayers(ZombieRuleComponent component) { + if (component.InfectedChosen) + return; + component.InfectedChosen = true; + var allPlayers = _playerManager.ServerSessions.ToList(); var playerList = new List(); var prefList = new List(); foreach (var player in allPlayers) { - // TODO: A - if (player.AttachedEntity != null && HasComp(player.AttachedEntity)) - { - playerList.Add(player); + if (player.AttachedEntity == null || !HasComp(player.AttachedEntity)) + continue; + playerList.Add(player); - var pref = (HumanoidCharacterProfile) _prefs.GetPreferences(player.UserId).SelectedCharacter; - if (pref.AntagPreferences.Contains(component.PatientZeroPrototypeID)) - prefList.Add(player); - } + var pref = (HumanoidCharacterProfile) _prefs.GetPreferences(player.UserId).SelectedCharacter; + if (pref.AntagPreferences.Contains(component.PatientZeroPrototypeId)) + prefList.Add(player); } if (playerList.Count == 0) return; - var playersPerInfected = _cfg.GetCVar(CCVars.ZombiePlayersPerInfected); - var maxInfected = _cfg.GetCVar(CCVars.ZombieMaxInitialInfected); - var numInfected = Math.Max(1, (int) Math.Min( - Math.Floor((double) playerList.Count / playersPerInfected), maxInfected)); + Math.Floor((double) playerList.Count / component.PlayersPerInfected), component.MaxInitialInfected)); - // How long the zombies have as a group to decide to begin their attack. - // Varies randomly from 20 to 30 minutes. After this the virus begins and they start - // taking zombie virus damage. - var groupTimelimit = _random.NextFloat(component.MinZombieForceSecs, component.MaxZombieForceSecs); - for (var i = 0; i < numInfected; i++) + var totalInfected = 0; + while (totalInfected < numInfected) { IPlayerSession zombie; if (prefList.Count == 0) { if (playerList.Count == 0) { - Logger.InfoS("preset", "Insufficient number of players. stopping selection."); + Log.Info("Insufficient number of players. stopping selection."); break; } - zombie = _random.PickAndTake(playerList); - Logger.InfoS("preset", "Insufficient preferred patient 0, picking at random."); + zombie = _random.Pick(playerList); + Log.Info("Insufficient preferred patient 0, picking at random."); } else { - zombie = _random.PickAndTake(prefList); - playerList.Remove(zombie); - Logger.InfoS("preset", "Selected a patient 0."); + zombie = _random.Pick(prefList); + Log.Info("Selected a patient 0."); } - var mind = zombie.Data.ContentData()?.Mind; - if (mind == null) - { - Logger.ErrorS("preset", "Failed getting mind for picked patient 0."); + prefList.Remove(zombie); + playerList.Remove(zombie); + if (zombie.Data.ContentData()?.Mind is not { } mind || mind.OwnedEntity is not { } ownedEntity) continue; - } - DebugTools.AssertNotNull(mind.OwnedEntity); - _mindSystem.AddRole(mind, new ZombieRole(mind, _prototypeManager.Index(component.PatientZeroPrototypeID))); + totalInfected++; - var inCharacterName = string.Empty; - // Create some variation between the times of each zombie, relative to the time of the group as a whole. - var personalDelay = _random.NextFloat(0.0f, component.PlayerZombieForceVariationSecs); - if (mind.OwnedEntity != null) - { - var pending = EnsureComp(mind.OwnedEntity.Value); - // Only take damage after this many seconds - pending.InfectedSecs = -(int)(groupTimelimit + personalDelay); - EnsureComp(mind.OwnedEntity.Value); - inCharacterName = MetaData(mind.OwnedEntity.Value).EntityName; + _mindSystem.AddRole(mind, new ZombieRole(mind, _prototypeManager.Index(component.PatientZeroPrototypeId))); - var action = new InstantAction(_prototypeManager.Index(ZombieRuleComponent.ZombifySelfActionPrototype)); - _action.AddAction(mind.OwnedEntity.Value, action, null); - } + var pending = EnsureComp(ownedEntity); + pending.GracePeriod = _random.Next(component.MinInitialInfectedGrace, component.MaxInitialInfectedGrace); + EnsureComp(ownedEntity); + EnsureComp(ownedEntity); + var inCharacterName = MetaData(ownedEntity).EntityName; + var action = new InstantAction(_prototypeManager.Index(ZombieRuleComponent.ZombifySelfActionPrototype)); + _action.AddAction(mind.OwnedEntity.Value, action, null); - if (mind.Session != null) - { - var message = Loc.GetString("zombie-patientzero-role-greeting"); - var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message)); + var message = Loc.GetString("zombie-patientzero-role-greeting"); + var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message)); - //gets the names now in case the players leave. - //this gets unhappy if people with the same name get chose. Probably shouldn't happen. - component.InitialInfectedNames.Add(inCharacterName, mind.Session.Name); + //gets the names now in case the players leave. + //this gets unhappy if people with the same name get chosen. Probably shouldn't happen. + component.InitialInfectedNames.Add(inCharacterName, zombie.Name); - // I went all the way to ChatManager.cs and all i got was this lousy T-shirt - // You got a free T-shirt!?!? - _chatManager.ChatMessageToOne(Shared.Chat.ChatChannel.Server, message, - wrappedMessage, default, false, mind.Session.ConnectedClient, Color.Plum); - } + // I went all the way to ChatManager.cs and all i got was this lousy T-shirt + // You got a free T-shirt!?!? + _chatManager.ChatMessageToOne(Shared.Chat.ChatChannel.Server, message, + wrappedMessage, default, false, zombie.ConnectedClient, Color.Plum); + _audio.PlayGlobal(component.InitialInfectedSound, ownedEntity); } } } diff --git a/Content.Server/Zombies/IncurableZombieComponent.cs b/Content.Server/Zombies/IncurableZombieComponent.cs new file mode 100644 index 0000000000..79c1af5529 --- /dev/null +++ b/Content.Server/Zombies/IncurableZombieComponent.cs @@ -0,0 +1,10 @@ +namespace Content.Server.Zombies; + +/// +/// This is used for a zombie that cannot be cured by any methods. +/// +[RegisterComponent] +public sealed class IncurableZombieComponent : Component +{ + +} diff --git a/Content.Server/Zombies/PendingZombieComponent.cs b/Content.Server/Zombies/PendingZombieComponent.cs index 83e86467f6..929c702ec4 100644 --- a/Content.Server/Zombies/PendingZombieComponent.cs +++ b/Content.Server/Zombies/PendingZombieComponent.cs @@ -1,5 +1,4 @@ using Content.Shared.Damage; -using Content.Shared.FixedPoint; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; namespace Content.Server.Zombies; @@ -10,49 +9,46 @@ namespace Content.Server.Zombies; [RegisterComponent] public sealed class PendingZombieComponent : Component { + /// + /// Damage dealt every second to infected individuals. + /// [DataField("damage")] public DamageSpecifier Damage = new() { DamageDict = new () { - { "Blunt", 0.8 }, - { "Toxin", 0.2 }, + { "Blunt", 0.25 }, + { "Poison", 0.1 }, } }; + /// + /// A multiplier for applied when the entity is in critical condition. + /// + [DataField("critDamageMultiplier")] + public float CritDamageMultiplier = 10f; + [DataField("nextTick", customTypeSerializer:typeof(TimeOffsetSerializer))] public TimeSpan NextTick; /// - /// Scales damage over time. + /// The amount of time left before the infected begins to take damage. /// - [ViewVariables(VVAccess.ReadWrite), DataField("infectedSecs")] - public int InfectedSecs; + [DataField("gracePeriod"), ViewVariables(VVAccess.ReadWrite)] + public TimeSpan GracePeriod = TimeSpan.Zero; /// - /// Number of seconds that a typical infection will last before the player is totally overwhelmed with damage and - /// dies. + /// The chance each second that a warning will be shown. /// - [ViewVariables(VVAccess.ReadWrite), DataField("maxInfectionLength")] - public float MaxInfectionLength = 120f; + [DataField("infectionWarningChance")] + public float InfectionWarningChance = 0.0166f; /// - /// Infection warnings are shown as popups, times are in seconds. - /// -ve times shown to initial zombies (once timer counts from -ve to 0 the infection starts) - /// +ve warnings are in seconds after being bitten + /// Infection warnings shown as popups /// [DataField("infectionWarnings")] - public Dictionary InfectionWarnings = new() + public List InfectionWarnings = new() { - {-45, "zombie-infection-warning"}, - {-30, "zombie-infection-warning"}, - {10, "zombie-infection-underway"}, - {25, "zombie-infection-underway"}, + "zombie-infection-warning", + "zombie-infection-underway" }; - - /// - /// A minimum multiplier applied to Damage once you are in crit to get you dead and ready for your next life - /// as fast as possible. - /// - [DataField("minimumCritMultiplier")] - public float MinimumCritMultiplier = 10; } diff --git a/Content.Server/Zombies/ZombieImmuneComponent.cs b/Content.Server/Zombies/ZombieImmuneComponent.cs index 5c2b8b3ce7..1a8517d258 100644 --- a/Content.Server/Zombies/ZombieImmuneComponent.cs +++ b/Content.Server/Zombies/ZombieImmuneComponent.cs @@ -1,8 +1,8 @@ -namespace Content.Server.Zombies +namespace Content.Server.Zombies; + +[RegisterComponent] +public sealed class ZombieImmuneComponent : Component { - [RegisterComponent] - public sealed class ZombieImmuneComponent : Component - { - //still no - } + //still no } + diff --git a/Content.Server/Zombies/ZombifyOnDeathSystem.cs b/Content.Server/Zombies/ZombieSystem.Transform.cs similarity index 67% rename from Content.Server/Zombies/ZombifyOnDeathSystem.cs rename to Content.Server/Zombies/ZombieSystem.Transform.cs index 9b5b553808..a016ca4a92 100644 --- a/Content.Server/Zombies/ZombifyOnDeathSystem.cs +++ b/Content.Server/Zombies/ZombieSystem.Transform.cs @@ -1,10 +1,7 @@ -using Content.Server.Administration.Commands; using Content.Server.Atmos.Components; using Content.Server.Body.Components; -using Content.Server.Body.Systems; using Content.Server.Chat; using Content.Server.Chat.Managers; -using Content.Server.Chat.Systems; using Content.Server.Ghost.Roles.Components; using Content.Server.Humanoid; using Content.Server.IdentityManagement; @@ -12,28 +9,33 @@ using Content.Server.Inventory; using Content.Server.Mind.Commands; using Content.Server.Mind.Components; using Content.Server.Nutrition.Components; -using Content.Server.Popups; using Content.Server.Roles; using Content.Server.Speech.Components; using Content.Server.Temperature.Components; -using Content.Server.Traitor; using Content.Shared.CombatMode; using Content.Shared.Damage; using Content.Shared.Hands.Components; using Content.Shared.Hands.EntitySystems; using Content.Server.Mind; +using Content.Server.NPC; +using Content.Server.NPC.Components; +using Content.Server.NPC.HTN; +using Content.Server.NPC.Systems; +using Content.Server.RoundEnd; using Content.Shared.Humanoid; using Content.Shared.Mobs; using Content.Shared.Mobs.Components; using Content.Shared.Mobs.Systems; -using Content.Shared.Movement.Components; using Content.Shared.Movement.Systems; using Content.Shared.Nutrition.Components; using Content.Shared.Popups; using Content.Shared.Roles; +using Content.Shared.Tools; +using Content.Shared.Tools.Components; using Content.Shared.Weapons.Melee; using Content.Shared.Zombies; -using Robust.Shared.Prototypes; +using Robust.Shared.Audio; +using Robust.Shared.Utility; namespace Content.Server.Zombies { @@ -43,32 +45,20 @@ namespace Content.Server.Zombies /// /// Don't Shitcode Open Inside /// - public sealed class ZombifyOnDeathSystem : EntitySystem + public sealed partial class ZombieSystem { - [Dependency] private readonly SharedHandsSystem _sharedHands = default!; - [Dependency] private readonly PopupSystem _popupSystem = default!; - [Dependency] private readonly BloodstreamSystem _bloodstream = default!; - [Dependency] private readonly ServerInventorySystem _serverInventory = default!; - [Dependency] private readonly DamageableSystem _damageable = default!; - [Dependency] private readonly HumanoidAppearanceSystem _sharedHuApp = default!; + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly ServerInventorySystem _inventory = default!; + [Dependency] private readonly NpcFactionSystem _faction = default!; + [Dependency] private readonly NPCSystem _npc = default!; + [Dependency] private readonly HumanoidAppearanceSystem _humanoidAppearance = default!; [Dependency] private readonly IdentitySystem _identity = default!; [Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!; - [Dependency] private readonly AutoEmoteSystem _autoEmote = default!; - [Dependency] private readonly EmoteOnDamageSystem _emoteOnDamage = default!; [Dependency] private readonly SharedCombatModeSystem _combat = default!; [Dependency] private readonly IChatManager _chatMan = default!; - [Dependency] private readonly IPrototypeManager _proto = default!; - [Dependency] private readonly MobStateSystem _mobState = default!; + [Dependency] private readonly MindSystem _mind = default!; [Dependency] private readonly MobThresholdSystem _mobThreshold = default!; - [Dependency] private readonly MindSystem _mindSystem = default!; - [Dependency] private readonly SharedAudioSystem _audioSystem = default!; - - public override void Initialize() - { - base.Initialize(); - - SubscribeLocalEvent(OnDamageChanged); - } + [Dependency] private readonly SharedAudioSystem _audio = default!; /// /// Handles an entity turning into a zombie when they die or go into crit @@ -86,6 +76,7 @@ namespace Content.Server.Zombies /// It handles both humanoid and nonhumanoid transformation and everything should be called through it. /// /// the entity being zombified + /// /// /// ALRIGHT BIG BOY. YOU'VE COME TO THE LAYER OF THE BEAST. THIS IS YOUR WARNING. /// This function is the god function for zombie stuff, and it is cursed. I have @@ -126,8 +117,7 @@ namespace Content.Server.Zombies var melee = EnsureComp(target); melee.ClickAnimation = zombiecomp.AttackAnimation; melee.WideAnimation = zombiecomp.AttackAnimation; - melee.Range = 1.5f; - Dirty(melee); + melee.Range = 1.2f; if (mobState.CurrentState == MobState.Alive) { @@ -149,24 +139,38 @@ namespace Content.Server.Zombies if (TryComp(target, out var stream)) zombiecomp.BeforeZombifiedBloodReagent = stream.BloodReagent; - _sharedHuApp.SetSkinColor(target, zombiecomp.SkinColor, verify: false, humanoid: huApComp); - _sharedHuApp.SetBaseLayerColor(target, HumanoidVisualLayers.Eyes, zombiecomp.EyeColor, humanoid: huApComp); + _humanoidAppearance.SetSkinColor(target, zombiecomp.SkinColor, verify: false, humanoid: huApComp); + _humanoidAppearance.SetBaseLayerColor(target, HumanoidVisualLayers.Eyes, zombiecomp.EyeColor, humanoid: huApComp); // this might not resync on clone? - _sharedHuApp.SetBaseLayerId(target, HumanoidVisualLayers.Tail, zombiecomp.BaseLayerExternal, humanoid: huApComp); - _sharedHuApp.SetBaseLayerId(target, HumanoidVisualLayers.HeadSide, zombiecomp.BaseLayerExternal, humanoid: huApComp); - _sharedHuApp.SetBaseLayerId(target, HumanoidVisualLayers.HeadTop, zombiecomp.BaseLayerExternal, humanoid: huApComp); - _sharedHuApp.SetBaseLayerId(target, HumanoidVisualLayers.Snout, zombiecomp.BaseLayerExternal, humanoid: huApComp); + _humanoidAppearance.SetBaseLayerId(target, HumanoidVisualLayers.Tail, zombiecomp.BaseLayerExternal, humanoid: huApComp); + _humanoidAppearance.SetBaseLayerId(target, HumanoidVisualLayers.HeadSide, zombiecomp.BaseLayerExternal, humanoid: huApComp); + _humanoidAppearance.SetBaseLayerId(target, HumanoidVisualLayers.HeadTop, zombiecomp.BaseLayerExternal, humanoid: huApComp); + _humanoidAppearance.SetBaseLayerId(target, HumanoidVisualLayers.Snout, zombiecomp.BaseLayerExternal, humanoid: huApComp); //This is done here because non-humanoids shouldn't get baller damage //lord forgive me for the hardcoded damage - DamageSpecifier dspec = new(); - dspec.DamageDict.Add("Slash", 13); - dspec.DamageDict.Add("Piercing", 7); - dspec.DamageDict.Add("Structural", 10); + DamageSpecifier dspec = new() + { + DamageDict = new() + { + { "Slash", 13 }, + { "Piercing", 7 }, + { "Structural", 10 } + } + }; melee.Damage = dspec; + + // humanoid zombies get to pry open doors and shit + var tool = EnsureComp(target); + tool.SpeedModifier = 0.75f; + tool.Qualities = new ("Prying"); + tool.UseSound = new SoundPathSpecifier("/Audio/Items/crowbar.ogg"); + Dirty(tool); } + Dirty(melee); + //The zombie gets the assigned damage weaknesses and strengths _damageable.SetDamageModifierSetId(target, "Zombie"); @@ -177,51 +181,60 @@ namespace Content.Server.Zombies _bloodstream.ChangeBloodReagent(target, zombiecomp.NewBloodReagent); //This is specifically here to combat insuls, because frying zombies on grilles is funny as shit. - _serverInventory.TryUnequip(target, "gloves", true, true); + _inventory.TryUnequip(target, "gloves", true, true); //Should prevent instances of zombies using comms for information they shouldnt be able to have. - _serverInventory.TryUnequip(target, "ears", true, true); + _inventory.TryUnequip(target, "ears", true, true); //popup - _popupSystem.PopupEntity(Loc.GetString("zombie-transform", ("target", target)), target, PopupType.LargeCaution); + _popup.PopupEntity(Loc.GetString("zombie-transform", ("target", target)), target, PopupType.LargeCaution); //Make it sentient if it's an animal or something - if (!HasComp(target)) //this component is cursed and fucks shit up - MakeSentientCommand.MakeSentient(target, EntityManager); + MakeSentientCommand.MakeSentient(target, EntityManager); //Make the zombie not die in the cold. Good for space zombies if (TryComp(target, out var tempComp)) tempComp.ColdDamage.ClampMax(0); - // Zombies can revive themselves _mobThreshold.SetAllowRevives(target, true); - //Heals the zombie from all the damage it took while human if (TryComp(target, out var damageablecomp)) _damageable.SetAllDamage(target, damageablecomp, 0); + _mobState.ChangeMobState(target, MobState.Alive); + _mobThreshold.SetAllowRevives(target, false); - // Revive them now - if (TryComp(target, out var mobstate) && mobstate.CurrentState==MobState.Dead) - _mobState.ChangeMobState(target, MobState.Alive, mobstate); + var factionComp = EnsureComp(target); + foreach (var id in new List(factionComp.Factions)) + { + _faction.RemoveFaction(target, id); + } + _faction.AddFaction(target, "Zombie"); //gives it the funny "Zombie ___" name. var meta = MetaData(target); zombiecomp.BeforeZombifiedEntityName = meta.EntityName; - meta.EntityName = Loc.GetString("zombie-name-prefix", ("target", meta.EntityName)); + _metaData.SetEntityName(target, Loc.GetString("zombie-name-prefix", ("target", meta.EntityName)), meta); _identity.QueueIdentityUpdate(target); //He's gotta have a mind var mindComp = EnsureComp(target); - if (_mindSystem.TryGetMind(target, out var mind, mindComp) && _mindSystem.TryGetSession(mind, out var session)) + if (_mind.TryGetMind(target, out var mind, mindComp) && _mind.TryGetSession(mind, out var session)) { //Zombie role for player manifest - _mindSystem.AddRole(mind, new ZombieRole(mind, _proto.Index(zombiecomp.ZombieRoleId))); + _mind.AddRole(mind, new ZombieRole(mind, _protoManager.Index(zombiecomp.ZombieRoleId))); //Greeting message for new bebe zombers _chatMan.DispatchServerMessage(session, Loc.GetString("zombie-infection-greeting")); // Notificate player about new role assignment - _audioSystem.PlayGlobal(zombiecomp.GreetSoundNotification, session); + _audio.PlayGlobal(zombiecomp.GreetSoundNotification, session); + } + else + { + var htn = EnsureComp(target); + htn.RootTask = "SimpleHostileCompound"; + htn.Blackboard.SetValue(NPCBlackboard.Owner, target); + _npc.WakeNPC(target, htn); } if (!HasComp(target) && !mindComp.HasMind) //this specific component gives build test trouble so pop off, ig @@ -236,19 +249,25 @@ namespace Content.Server.Zombies //Goes through every hand, drops the items in it, then removes the hand //may become the source of various bugs. - foreach (var hand in _sharedHands.EnumerateHands(target)) + if (TryComp(target, out var hands)) { - _sharedHands.SetActiveHand(target, hand); - _sharedHands.DoDrop(target, hand); - _sharedHands.RemoveHand(target, hand.Name); + foreach (var hand in _hands.EnumerateHands(target)) + { + _hands.SetActiveHand(target, hand, hands); + _hands.DoDrop(target, hand, handsComp: hands); + _hands.RemoveHand(target, hand.Name, hands); + } + + RemComp(target, hands); } - RemComp(target); + // No longer waiting to become a zombie: // Requires deferral because this is (probably) the event which called ZombifyEntity in the first place. RemCompDeferred(target); //zombie gamemode stuff - RaiseLocalEvent(new EntityZombifiedEvent(target)); + var ev = new EntityZombifiedEvent(target); + RaiseLocalEvent(target, ref ev, true); //zombies get slowdown once they convert _movementSpeedModifier.RefreshMovementSpeedModifiers(target); } diff --git a/Content.Server/Zombies/ZombieSystem.cs b/Content.Server/Zombies/ZombieSystem.cs index b21892c015..eee9ed2c1e 100644 --- a/Content.Server/Zombies/ZombieSystem.cs +++ b/Content.Server/Zombies/ZombieSystem.cs @@ -4,10 +4,8 @@ using Content.Server.Chat; using Content.Server.Chat.Systems; using Content.Server.Cloning; using Content.Server.Drone.Components; -using Content.Server.Humanoid; using Content.Server.Inventory; using Content.Shared.Bed.Sleep; -using Content.Shared.Chemistry.Components; using Content.Server.Emoting.Systems; using Content.Server.Speech.EntitySystems; using Content.Shared.Damage; @@ -24,20 +22,18 @@ using Robust.Shared.Timing; namespace Content.Server.Zombies { - public sealed class ZombieSystem : SharedZombieSystem + public sealed partial class ZombieSystem : SharedZombieSystem { [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IPrototypeManager _protoManager = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly BloodstreamSystem _bloodstream = default!; [Dependency] private readonly DamageableSystem _damageable = default!; - [Dependency] private readonly ZombifyOnDeathSystem _zombify = default!; [Dependency] private readonly ServerInventorySystem _inv = default!; [Dependency] private readonly ChatSystem _chat = default!; [Dependency] private readonly AutoEmoteSystem _autoEmote = default!; [Dependency] private readonly EmoteOnDamageSystem _emoteOnDamage = default!; - [Dependency] private readonly HumanoidAppearanceSystem _humanoidSystem = default!; - [Dependency] private readonly MobThresholdSystem _mobThreshold = default!; + [Dependency] private readonly MetaDataSystem _metaData = default!; [Dependency] private readonly MobStateSystem _mobState = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; @@ -55,61 +51,46 @@ namespace Content.Server.Zombies SubscribeLocalEvent(OnSleepAttempt); SubscribeLocalEvent(OnPendingMapInit); - SubscribeLocalEvent(OnPendingMobState); + + SubscribeLocalEvent(OnDamageChanged); } private void OnPendingMapInit(EntityUid uid, PendingZombieComponent component, MapInitEvent args) { - component.NextTick = _timing.CurTime; + component.NextTick = _timing.CurTime + TimeSpan.FromSeconds(1f); } public override void Update(float frameTime) { base.Update(frameTime); - var query = EntityQueryEnumerator(); var curTime = _timing.CurTime; - var zombQuery = EntityQueryEnumerator(); - // Hurt the living infected + var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var comp, out var damage, out var mobState)) { // Process only once per second - if (comp.NextTick + TimeSpan.FromSeconds(1) > curTime) + if (comp.NextTick > curTime) continue; - comp.NextTick = curTime; + comp.NextTick = curTime + TimeSpan.FromSeconds(1f); - comp.InfectedSecs += 1; - // See if there should be a warning popup for the player. - if (comp.InfectionWarnings.TryGetValue(comp.InfectedSecs, out var popupStr)) - { - _popup.PopupEntity(Loc.GetString(popupStr), uid, uid); - } - - if (comp.InfectedSecs < 0) - { - // This zombie has a latent virus, probably set up by ZombieRuleSystem. No damage yet. + comp.GracePeriod -= TimeSpan.FromSeconds(1f); + if (comp.GracePeriod > TimeSpan.Zero) continue; - } - // Pain of becoming a zombie grows over time - // By scaling the number of seconds we have an accessible way to scale this exponential function. - // The function was hand tuned to 120 seconds, hence the 120 constant here. - var scaledSeconds = (120.0f / comp.MaxInfectionLength) * comp.InfectedSecs; + if (_random.Prob(comp.InfectionWarningChance)) + _popup.PopupEntity(Loc.GetString(_random.Pick(comp.InfectionWarnings)), uid, uid); - // 1x at 30s, 3x at 60s, 6x at 90s, 10x at 120s. Limit at 20x so we don't gib you. - var painMultiple = Math.Min(20f, 0.1f + 0.02f * scaledSeconds + 0.0005f * scaledSeconds * scaledSeconds); - if (mobState.CurrentState == MobState.Critical) - { - // Speed up their transformation when they are (or have been) in crit by ensuring their damage - // multiplier is at least 10x - painMultiple = Math.Max(comp.MinimumCritMultiplier, painMultiple); - } - _damageable.TryChangeDamage(uid, comp.Damage * painMultiple, true, false, damage); + var multiplier = _mobState.IsCritical(uid, mobState) + ? comp.CritDamageMultiplier + : 1f; + + _damageable.TryChangeDamage(uid, comp.Damage * multiplier, true, false, damage); } // Heal the zombified + var zombQuery = EntityQueryEnumerator(); while (zombQuery.MoveNext(out var uid, out var comp, out var damage, out var mobState)) { // Process only once per second @@ -118,22 +99,15 @@ namespace Content.Server.Zombies comp.NextTick = curTime; - if (comp.Permadeath) - { - // No healing + if (_mobState.IsDead(uid, mobState)) continue; - } - if (mobState.CurrentState == MobState.Alive) - { - // Gradual healing for living zombies. - _damageable.TryChangeDamage(uid, comp.Damage, true, false, damage); - } - else if (_random.Prob(comp.ZombieReviveChance)) - { - // There's a small chance to reverse all the zombie's damage (damage.Damage) in one go - _damageable.TryChangeDamage(uid, -damage.Damage, true, false, damage); - } + var multiplier = _mobState.IsCritical(uid, mobState) + ? comp.PassiveHealingCritMultiplier + : 1f; + + // Gradual healing for living zombies. + _damageable.TryChangeDamage(uid, comp.PassiveHealing * multiplier, true, false, damage); } } @@ -176,28 +150,6 @@ namespace Content.Server.Zombies // Stop random groaning _autoEmote.RemoveEmote(uid, "ZombieGroan"); - - if (args.NewMobState == MobState.Dead) - { - // Roll to see if this zombie is not coming back. - // Note that due to damage reductions it takes a lot of hits to gib a zombie without this. - if (_random.Prob(component.ZombiePermadeathChance)) - { - // You're dead! No reviving for you. - _mobThreshold.SetAllowRevives(uid, false); - component.Permadeath = true; - _popup.PopupEntity(Loc.GetString("zombie-permadeath"), uid, uid); - } - } - } - } - - private void OnPendingMobState(EntityUid uid, PendingZombieComponent pending, MobStateChangedEvent args) - { - if (args.NewMobState == MobState.Critical) - { - // Immediately jump to an active virus when you crit - pending.InfectedSecs = Math.Max(0, pending.InfectedSecs); } } @@ -238,7 +190,7 @@ namespace Content.Server.Zombies private void OnMeleeHit(EntityUid uid, ZombieComponent component, MeleeHitEvent args) { - if (!EntityManager.TryGetComponent(args.User, out var zombieComp)) + if (!TryComp(args.User, out _)) return; if (!args.HitEntities.Any()) @@ -254,29 +206,25 @@ namespace Content.Server.Zombies if (HasComp(entity)) { - args.BonusDamage = -args.BaseDamage * zombieComp.OtherZombieDamageCoefficient; + args.BonusDamage = -args.BaseDamage; } else { if (!HasComp(entity) && _random.Prob(GetZombieInfectionChance(entity, component))) { - var pending = EnsureComp(entity); - pending.MaxInfectionLength = _random.NextFloat(0.25f, 1.0f) * component.ZombieInfectionTurnTime; + EnsureComp(entity); EnsureComp(entity); } } - if ((mobState.CurrentState == MobState.Dead || mobState.CurrentState == MobState.Critical) - && !HasComp(entity)) + if (_mobState.IsIncapacitated(entity, mobState) && !HasComp(entity)) { - _zombify.ZombifyEntity(entity); + ZombifyEntity(entity); args.BonusDamage = -args.BaseDamage; } else if (mobState.CurrentState == MobState.Alive) //heals when zombies bite live entities { - var healingSolution = new Solution(); - healingSolution.AddReagent("Bicaridine", 1.00); //if OP, reduce/change chem - _bloodstream.TryAddToChemicals(args.User, healingSolution); + _damageable.TryChangeDamage(uid, component.HealingOnBite, true, false); } } } @@ -286,9 +234,10 @@ namespace Content.Server.Zombies /// /// the entity having the ZombieComponent /// the entity you want to unzombify (different from source in case of cloning, for example) + /// /// /// this currently only restore the name and skin/eye color from before zombified - /// TODO: reverse everything else done in ZombifyEntity + /// TODO: completely rethink how zombies are done to allow reversal. /// public bool UnZombify(EntityUid source, EntityUid target, ZombieComponent? zombiecomp) { @@ -297,13 +246,13 @@ namespace Content.Server.Zombies foreach (var (layer, info) in zombiecomp.BeforeZombifiedCustomBaseLayers) { - _humanoidSystem.SetBaseLayerColor(target, layer, info.Color); - _humanoidSystem.SetBaseLayerId(target, layer, info.ID); + _humanoidAppearance.SetBaseLayerColor(target, layer, info.Color); + _humanoidAppearance.SetBaseLayerId(target, layer, info.ID); } - _humanoidSystem.SetSkinColor(target, zombiecomp.BeforeZombifiedSkinColor); + _humanoidAppearance.SetSkinColor(target, zombiecomp.BeforeZombifiedSkinColor); _bloodstream.ChangeBloodReagent(target, zombiecomp.BeforeZombifiedBloodReagent); - MetaData(target).EntityName = zombiecomp.BeforeZombifiedEntityName; + _metaData.SetEntityName(target, zombiecomp.BeforeZombifiedEntityName); return true; } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 1738aec4b4..76b9d0ea31 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -399,12 +399,6 @@ namespace Content.Shared.CCVar public static readonly CVarDef ZombieMinPlayers = CVarDef.Create("zombie.min_players", 20); - public static readonly CVarDef ZombieMaxInitialInfected = - CVarDef.Create("zombie.max_initial_infected", 6); - - public static readonly CVarDef ZombiePlayersPerInfected = - CVarDef.Create("zombie.players_per_infected", 10); - /* * Pirates */ diff --git a/Content.Shared/Zombies/ZombieComponent.cs b/Content.Shared/Zombies/ZombieComponent.cs index dd0d8c4115..125eeaf0bf 100644 --- a/Content.Shared/Zombies/ZombieComponent.cs +++ b/Content.Shared/Zombies/ZombieComponent.cs @@ -1,7 +1,9 @@ using Content.Shared.Chat.Prototypes; +using Content.Shared.Chemistry.Reagent; using Content.Shared.Damage; using Content.Shared.Roles; using Content.Shared.Humanoid; +using Content.Shared.StatusIcon; using Robust.Shared.Audio; using Robust.Shared.GameStates; using Robust.Shared.Prototypes; @@ -9,148 +11,135 @@ using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; using static Content.Shared.Humanoid.HumanoidAppearanceState; -namespace Content.Shared.Zombies +namespace Content.Shared.Zombies; + +[RegisterComponent, NetworkedComponent] +public sealed class ZombieComponent : Component { - [RegisterComponent, NetworkedComponent] - public sealed class ZombieComponent : Component + /// + /// The baseline infection chance you have if you are completely nude + /// + [ViewVariables(VVAccess.ReadWrite)] + public float MaxZombieInfectionChance = 0.50f; + + /// + /// The minimum infection chance possible. This is simply to prevent + /// being invincible by bundling up. + /// + [ViewVariables(VVAccess.ReadWrite)] + public float MinZombieInfectionChance = 0.20f; + + [ViewVariables(VVAccess.ReadWrite)] + public float ZombieMovementSpeedDebuff = 0.70f; + + /// + /// The skin color of the zombie + /// + [DataField("skinColor")] + public Color SkinColor = new(0.45f, 0.51f, 0.29f); + + /// + /// The eye color of the zombie + /// + [DataField("eyeColor")] + public Color EyeColor = new(0.96f, 0.13f, 0.24f); + + /// + /// The base layer to apply to any 'external' humanoid layers upon zombification. + /// + [DataField("baseLayerExternal")] + public string BaseLayerExternal = "MobHumanoidMarkingMatchSkin"; + + /// + /// The attack arc of the zombie + /// + [DataField("attackArc", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string AttackAnimation = "WeaponArcBite"; + + /// + /// The role prototype of the zombie antag role + /// + [DataField("zombieRoleId", customTypeSerializer: typeof(PrototypeIdSerializer))] + public readonly string ZombieRoleId = "Zombie"; + + /// + /// The EntityName of the humanoid to restore in case of cloning + /// + [DataField("beforeZombifiedEntityName"), ViewVariables(VVAccess.ReadOnly)] + public string BeforeZombifiedEntityName = string.Empty; + + /// + /// The CustomBaseLayers of the humanoid to restore in case of cloning + /// + [DataField("beforeZombifiedCustomBaseLayers")] + public Dictionary BeforeZombifiedCustomBaseLayers = new (); + + /// + /// The skin color of the humanoid to restore in case of cloning + /// + [DataField("beforeZombifiedSkinColor")] + public Color BeforeZombifiedSkinColor; + + [DataField("emoteId", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string? EmoteSoundsId = "Zombie"; + + public EmoteSoundsPrototype? EmoteSounds; + + [DataField("nextTick", customTypeSerializer:typeof(TimeOffsetSerializer))] + public TimeSpan NextTick; + + [DataField("zombieStatusIcon", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string ZombieStatusIcon = "ZombieFaction"; + + /// + /// Healing each second + /// + [DataField("passiveHealing")] + public DamageSpecifier PassiveHealing = new() { - /// - /// The coefficient of the damage reduction applied when a zombie - /// attacks another zombie. longe name - /// - [ViewVariables] - public float OtherZombieDamageCoefficient = 0.25f; - - /// - /// Chance that this zombie be permanently killed (rolled once on crit->death transition) - /// - [ViewVariables(VVAccess.ReadWrite)] - public float ZombiePermadeathChance = 0.80f; - - /// - /// Chance that this zombie will be healed (rolled each second when in crit or dead) - /// 3% means you have a 60% chance after 30 secs and a 84% chance after 60. - /// - [ViewVariables(VVAccess.ReadWrite)] - public float ZombieReviveChance = 0.03f; - - /// - /// Has this zombie stopped healing now that it's died for real? - /// - [ViewVariables(VVAccess.ReadWrite)] - public bool Permadeath = false; - - /// - /// The baseline infection chance you have if you are completely nude - /// - [ViewVariables(VVAccess.ReadWrite)] - public float MaxZombieInfectionChance = 0.50f; - - /// - /// The minimum infection chance possible. This is simply to prevent - /// being invincible by bundling up. - /// - [ViewVariables(VVAccess.ReadWrite)] - public float MinZombieInfectionChance = 0.20f; - - [ViewVariables(VVAccess.ReadWrite)] - public float ZombieMovementSpeedDebuff = 0.70f; - - /// - /// How long it takes our bite victims to turn in seconds (max). - /// Will roll 25% - 100% of this on bite. - /// - [DataField("zombieInfectionTurnTime"), ViewVariables(VVAccess.ReadWrite)] - public float ZombieInfectionTurnTime = 480.0f; - - /// - /// The skin color of the zombie - /// - [DataField("skinColor")] - public Color SkinColor = new(0.45f, 0.51f, 0.29f); - - /// - /// The eye color of the zombie - /// - [DataField("eyeColor")] - public Color EyeColor = new(0.96f, 0.13f, 0.24f); - - /// - /// The base layer to apply to any 'external' humanoid layers upon zombification. - /// - [DataField("baseLayerExternal")] - public string BaseLayerExternal = "MobHumanoidMarkingMatchSkin"; - - /// - /// The attack arc of the zombie - /// - [DataField("attackArc", customTypeSerializer: typeof(PrototypeIdSerializer))] - public string AttackAnimation = "WeaponArcBite"; - - /// - /// The role prototype of the zombie antag role - /// - [DataField("zombieRoleId", customTypeSerializer: typeof(PrototypeIdSerializer))] - public readonly string ZombieRoleId = "Zombie"; - - /// - /// The EntityName of the humanoid to restore in case of cloning - /// - [DataField("beforeZombifiedEntityName"), ViewVariables(VVAccess.ReadOnly)] - public string BeforeZombifiedEntityName = String.Empty; - - /// - /// The CustomBaseLayers of the humanoid to restore in case of cloning - /// - [DataField("beforeZombifiedCustomBaseLayers")] - public Dictionary BeforeZombifiedCustomBaseLayers = new (); - - /// - /// The skin color of the humanoid to restore in case of cloning - /// - [DataField("beforeZombifiedSkinColor")] - public Color BeforeZombifiedSkinColor; - - [DataField("emoteId", customTypeSerializer: typeof(PrototypeIdSerializer))] - public string? EmoteSoundsId = "Zombie"; - - public EmoteSoundsPrototype? EmoteSounds; - - [DataField("nextTick", customTypeSerializer:typeof(TimeOffsetSerializer))] - public TimeSpan NextTick; - - /// - /// Healing each second - /// - [DataField("damage")] public DamageSpecifier Damage = new() + DamageDict = new () { - DamageDict = new () - { - { "Blunt", -0.4 }, - { "Slash", -0.2 }, - { "Piercing", -0.2 }, - { "Heat", -0.2 }, - { "Cold", -0.2 }, - { "Shock", -0.2 }, - } - }; + { "Blunt", -0.4 }, + { "Slash", -0.2 }, + { "Piercing", -0.2 } + } + }; - /// - /// Path to antagonist alert sound. - /// - [DataField("greetSoundNotification")] - public SoundSpecifier GreetSoundNotification = new SoundPathSpecifier("/Audio/Ambience/Antag/zombie_start.ogg"); + /// + /// A multiplier applied to when the entity is in critical condition. + /// + [DataField("passiveHealingCritMultiplier")] + public float PassiveHealingCritMultiplier = 2f; - /// - /// The blood reagent of the humanoid to restore in case of cloning - /// - [DataField("beforeZombifiedBloodReagent")] - public string BeforeZombifiedBloodReagent = String.Empty; + /// + /// Healing given when a zombie bites a living being. + /// + [DataField("healingOnBite")] + public DamageSpecifier HealingOnBite = new() + { + DamageDict = new() + { + { "Blunt", -2 }, + { "Slash", -2 }, + { "Piercing", -2 } + } + }; - /// - /// The blood reagent to give the zombie. In case you want zombies that bleed milk, or something. - /// - [DataField("newBloodReagent")] - public string NewBloodReagent = "ZombieBlood"; - } + /// + /// Path to antagonist alert sound. + /// + [DataField("greetSoundNotification")] + public SoundSpecifier GreetSoundNotification = new SoundPathSpecifier("/Audio/Ambience/Antag/zombie_start.ogg"); + + /// + /// The blood reagent of the humanoid to restore in case of cloning + /// + [DataField("beforeZombifiedBloodReagent")] + public string BeforeZombifiedBloodReagent = string.Empty; + + /// + /// The blood reagent to give the zombie. In case you want zombies that bleed milk, or something. + /// + [DataField("newBloodReagent", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string NewBloodReagent = "ZombieBlood"; } diff --git a/Content.Shared/Zombies/ZombieEvents.cs b/Content.Shared/Zombies/ZombieEvents.cs index 9fa0e3c728..c7432e564a 100644 --- a/Content.Shared/Zombies/ZombieEvents.cs +++ b/Content.Shared/Zombies/ZombieEvents.cs @@ -6,6 +6,7 @@ namespace Content.Shared.Zombies; /// Event that is broadcast whenever an entity is zombified. /// Used by the zombie gamemode to track total infections. /// +[ByRefEvent] public readonly struct EntityZombifiedEvent { /// diff --git a/Resources/Locale/en-US/game-ticking/game-presets/preset-zombies.ftl b/Resources/Locale/en-US/game-ticking/game-presets/preset-zombies.ftl index 8c4e963ffa..efb5c6ef1e 100644 --- a/Resources/Locale/en-US/game-ticking/game-presets/preset-zombies.ftl +++ b/Resources/Locale/en-US/game-ticking/game-presets/preset-zombies.ftl @@ -1,16 +1,18 @@ zombie-title = Zombies -zombie-description = A virus able to animate the dead has been unleashed unto the station! Work with your crewmates to contain the outbreak and survive. +zombie-description = The undead have been unleashed on the station! Work with the crew to survive the outbreak and secure the station. zombie-not-enough-ready-players = Not enough players readied up for the game! There were {$readyPlayersCount} players readied up out of {$minimumPlayers} needed. Can't start Zombies. zombie-no-one-ready = No players readied up! Can't start Zombies. -zombie-patientzero-role-greeting = You are patient 0. Hide your infection, get supplies, and be prepared to turn once you die. +zombie-patientzero-role-greeting = You are an initial infected. Get supplies and prepare for your eventual transformation. Your goal is to overtake the station while infecting as many people as possible. zombie-healing = You feel a stirring in your flesh zombie-infection-warning = You feel the zombie virus take hold zombie-infection-underway = Your blood begins to thicken zombie-alone = You feel entirely alone. +zombie-shuttle-call = We have detected that the undead have overtaken the station. Dispatching an emergency shuttle to collect remaining personnel. + zombie-round-end-initial-count = {$initialCount -> [one] There was one initial infected: *[other] There were {$initialCount} initial infected: diff --git a/Resources/Locale/en-US/zombies/zombie.ftl b/Resources/Locale/en-US/zombies/zombie.ftl index dfcddf3308..a391a95b0d 100644 --- a/Resources/Locale/en-US/zombies/zombie.ftl +++ b/Resources/Locale/en-US/zombies/zombie.ftl @@ -1,9 +1,9 @@ zombie-transform = {CAPITALIZE(THE($target))} turned into a zombie! -zombie-infection-greeting = You have become a zombie. Your goal is to seek out the living and to try to infect them. Work together with your fellow zombies to overpower the remaining crewmates. +zombie-infection-greeting = You have become a zombie. Your goal is to seek out the living and to try to infect them. Work together with the other zombies to overtake the station. zombie-generic = zombie zombie-name-prefix = Zombified {$target} zombie-role-desc = A malevolent creature of the dead. -zombie-role-rules = You are an antagonist. Search out the living and bite them in order to infect them and turn them into zombies. Work together with other the zombies to overtake the station. +zombie-role-rules = You are an antagonist. Search out the living and bite them in order to infect them and turn them into zombies. Work together with the other zombies to overtake the station. zombie-permadeath = This time, you're dead for real. diff --git a/Resources/Prototypes/Damage/modifier_sets.yml b/Resources/Prototypes/Damage/modifier_sets.yml index 97d82cadc8..d58e8ba30b 100644 --- a/Resources/Prototypes/Damage/modifier_sets.yml +++ b/Resources/Prototypes/Damage/modifier_sets.yml @@ -140,14 +140,14 @@ Poison: 0.8 - type: damageModifierSet - id: Zombie #Blunt resistent and immune to biological threats, but can be hacked apart and burned + id: Zombie #Blunt resistant and immune to biological threats, but can be hacked apart and burned coefficients: Blunt: 0.7 Slash: 1.1 Piercing: 0.9 - Shock: 1.5 + Shock: 1.25 Cold: 0.3 - Heat: 2.0 + Heat: 1.5 Poison: 0.0 Radiation: 0.0 diff --git a/Resources/Prototypes/GameRules/events.yml b/Resources/Prototypes/GameRules/events.yml index 38d5853d60..df66266fc6 100644 --- a/Resources/Prototypes/GameRules/events.yml +++ b/Resources/Prototypes/GameRules/events.yml @@ -309,6 +309,11 @@ weight: 2.5 duration: 1 - type: ZombieRule + minStartDelay: 0 #let them know immediately + maxStartDelay: 10 + maxInitialInfected: 3 #fewer zombies + minInitialInfectedGrace: 300 #less time to prepare + maxInitialInfectedGrace: 450 - type: entity id: LoneOpsSpawn diff --git a/Resources/Prototypes/ai_factions.yml b/Resources/Prototypes/ai_factions.yml index 8011a9a9d9..9c8f001577 100644 --- a/Resources/Prototypes/ai_factions.yml +++ b/Resources/Prototypes/ai_factions.yml @@ -5,6 +5,7 @@ - Syndicate - Xeno - PetsNT + - Zombie - type: npcFaction id: NanoTrasen @@ -12,6 +13,7 @@ - SimpleHostile - Syndicate - Xeno + - Zombie - type: npcFaction id: Mouse @@ -26,6 +28,7 @@ hostile: - Mouse - SimpleHostile + - Zombie - Xeno - type: npcFaction @@ -35,6 +38,7 @@ - Syndicate - Passive - PetsNT + - Zombie - type: npcFaction id: SimpleNeutral @@ -46,6 +50,7 @@ - SimpleHostile - Xeno - PetsNT + - Zombie - type: npcFaction id: Xeno @@ -54,3 +59,14 @@ - Syndicate - Passive - PetsNT + - Zombie + +- type: npcFaction + id: Zombie + hostile: + - NanoTrasen + - SimpleNeutral + - SimpleHostile + - Syndicate + - Passive + - PetsNT diff --git a/Resources/Prototypes/secret_weights.yml b/Resources/Prototypes/secret_weights.yml index 396f493f51..b23daa2a4e 100644 --- a/Resources/Prototypes/secret_weights.yml +++ b/Resources/Prototypes/secret_weights.yml @@ -2,5 +2,5 @@ id: Secret weights: Nukeops: 0.25 - Traitor: 0.70 - # Zombie: 0.05 + Traitor: 0.65 + Zombie: 0.10