diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
index 01caaa4e07..7b6ca14ea1 100644
--- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
+++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs
@@ -56,11 +56,7 @@ public sealed partial class AdminVerbSystem
Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "bio"),
Act = () =>
{
- TryComp(args.Target, out MindComponent? mindComp);
- if (mindComp == null || mindComp.Mind == null)
- return;
-
- _zombify.ZombifyEntity(targetMindComp.Owner);
+ _zombify.ZombifyEntity(args.Target);
},
Impact = LogImpact.High,
Message = Loc.GetString("admin-verb-make-zombie"),
diff --git a/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs
index 17d87a1ec5..869765c14f 100644
--- a/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/ZombieRuleComponent.cs
@@ -8,4 +8,28 @@ public sealed class ZombieRuleComponent : Component
public string PatientZeroPrototypeID = "InitialInfected";
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/ZombieRuleSystem.cs b/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs
index a857c5c1c8..f7975e1560 100644
--- a/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs
@@ -251,6 +251,10 @@ public sealed class ZombieRuleSystem : GameRuleSystem
(int) Math.Min(
Math.Floor((double) playerList.Count / playersPerInfected), maxInfected));
+ // 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++)
{
IPlayerSession zombie;
@@ -283,9 +287,13 @@ public sealed class ZombieRuleSystem : GameRuleSystem
mind.AddRole(new TraitorRole(mind, _prototypeManager.Index(component.PatientZeroPrototypeID)));
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)
{
- EnsureComp(mind.OwnedEntity.Value);
+ 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;
diff --git a/Content.Server/Zombies/PendingZombieComponent.cs b/Content.Server/Zombies/PendingZombieComponent.cs
index 957e2a712a..83e86467f6 100644
--- a/Content.Server/Zombies/PendingZombieComponent.cs
+++ b/Content.Server/Zombies/PendingZombieComponent.cs
@@ -14,8 +14,7 @@ public sealed class PendingZombieComponent : Component
{
DamageDict = new ()
{
- { "Blunt", 0.5 },
- { "Cellular", 0.2 },
+ { "Blunt", 0.8 },
{ "Toxin", 0.2 },
}
};
@@ -23,7 +22,37 @@ public sealed class PendingZombieComponent : Component
[DataField("nextTick", customTypeSerializer:typeof(TimeOffsetSerializer))]
public TimeSpan NextTick;
- // Scales damage over time.
- [DataField("infectedSecs")]
+ ///
+ /// Scales damage over time.
+ ///
+ [ViewVariables(VVAccess.ReadWrite), DataField("infectedSecs")]
public int InfectedSecs;
+
+ ///
+ /// Number of seconds that a typical infection will last before the player is totally overwhelmed with damage and
+ /// dies.
+ ///
+ [ViewVariables(VVAccess.ReadWrite), DataField("maxInfectionLength")]
+ public float MaxInfectionLength = 120f;
+
+ ///
+ /// 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
+ ///
+ [DataField("infectionWarnings")]
+ public Dictionary InfectionWarnings = new()
+ {
+ {-45, "zombie-infection-warning"},
+ {-30, "zombie-infection-warning"},
+ {10, "zombie-infection-underway"},
+ {25, "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/ZombieSystem.cs b/Content.Server/Zombies/ZombieSystem.cs
index 8d5d0cd793..b051a7f89a 100644
--- a/Content.Server/Zombies/ZombieSystem.cs
+++ b/Content.Server/Zombies/ZombieSystem.cs
@@ -55,6 +55,7 @@ namespace Content.Server.Zombies
SubscribeLocalEvent(OnSleepAttempt);
SubscribeLocalEvent(OnPendingMapInit);
+ SubscribeLocalEvent(OnPendingMobState);
}
private void OnPendingMapInit(EntityUid uid, PendingZombieComponent component, MapInitEvent args)
@@ -65,24 +66,47 @@ namespace Content.Server.Zombies
public override void Update(float frameTime)
{
base.Update(frameTime);
- var query = EntityQueryEnumerator();
+ var query = EntityQueryEnumerator();
var curTime = _timing.CurTime;
var zombQuery = EntityQueryEnumerator();
// Hurt the living infected
- while (query.MoveNext(out var uid, out var comp, out var damage))
+ 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)
continue;
- comp.InfectedSecs += 1;
- // Pain of becoming a zombie grows over time
- // 1x at 30s, 3x at 60s, 6x at 90s, 10x at 120s.
- var pain_multiple = 0.1 + 0.02 * comp.InfectedSecs + 0.0005 * comp.InfectedSecs * comp.InfectedSecs;
comp.NextTick = curTime;
- _damageable.TryChangeDamage(uid, comp.Damage * pain_multiple, true, false, damage);
+
+ 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.
+ 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;
+
+ // 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);
}
// Heal the zombified
@@ -168,6 +192,15 @@ namespace Content.Server.Zombies
}
}
+ 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);
+ }
+ }
+
private float GetZombieInfectionChance(EntityUid uid, ZombieComponent component)
{
var baseChance = component.MaxZombieInfectionChance;
@@ -227,7 +260,8 @@ namespace Content.Server.Zombies
{
if (_random.Prob(GetZombieInfectionChance(entity, component)))
{
- EnsureComp(entity);
+ var pending = EnsureComp(entity);
+ pending.MaxInfectionLength = _random.NextFloat(0.25f, 1.0f) * component.ZombieInfectionTurnTime;
EnsureComp(entity);
}
}
diff --git a/Content.Server/Zombies/ZombifyOnDeathSystem.cs b/Content.Server/Zombies/ZombifyOnDeathSystem.cs
index d77024efad..a0a26eb6e2 100644
--- a/Content.Server/Zombies/ZombifyOnDeathSystem.cs
+++ b/Content.Server/Zombies/ZombifyOnDeathSystem.cs
@@ -232,7 +232,8 @@ namespace Content.Server.Zombies
}
RemComp(target);
// No longer waiting to become a zombie:
- RemComp(target);
+ // Requires deferral because this is (probably) the event which called ZombifyEntity in the first place.
+ RemCompDeferred(target);
//zombie gamemode stuff
RaiseLocalEvent(new EntityZombifiedEvent(target));
diff --git a/Content.Shared/Zombies/ZombieComponent.cs b/Content.Shared/Zombies/ZombieComponent.cs
index 87943531fc..1549d712c0 100644
--- a/Content.Shared/Zombies/ZombieComponent.cs
+++ b/Content.Shared/Zombies/ZombieComponent.cs
@@ -55,6 +55,13 @@ namespace Content.Shared.Zombies
[ViewVariables(VVAccess.ReadWrite)]
public float ZombieMovementSpeedDebuff = 0.75f;
+ ///
+ /// 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 = 240.0f;
+
///
/// The skin color of the zombie
///
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 cef09786de..8c4e963ffa 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
@@ -6,6 +6,8 @@ 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-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.