Part of the ZombieAI class that implements gore/dismemberment behaviour. It decides when a zombie loses head or arms (host authoritative), announces gibs to clients, applies bone overrides to visually fold removed parts, spawns particle blood bursts and plays gore sounds, and exposes console commands to toggle and force gibs.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// GORE: heads and arms come off, as in the original (`nz_zombiebase_moo.lua`, moo:3617-4027). Asked for as *"add limb tear
/// from zombies, like loosing their arms and head"* (2026-09-28), heads and arms first; legs and crawlers are a later step.
///
/// ⛔ NOTHING IS CUT OFF OR THROWN, EXACTLY AS THERE. The original shrinks the lost part's bones to nothing (`DeflateBones`,
/// `ManipulateBoneScale` at 1e-7), so the forearm or head folds into its joint and vanishes in a burst of blood and a gore sound.
/// The walker's skeleton carries the original's bone names, so the same subtrees go here, overridden every frame onto the joint at
/// a scale of nothing (`ApplyGore`).
///
/// WHEN, all from the original:
/// • THE HEAD, when a hit to the head kills (moo:4061-4077), or anything kills while Insta-Kill is on (moo:4057);
/// • THE HEAD, when a head hit on a zombie at 10% health or less does not kill it (moo:2303-2319): it walks on headless and
/// silent, spurting from the neck, and drops after ten spurts (moo:3283-3299);
/// • AN ARM, when a bullet hits it (moo:3672-3684), torn off at the elbow — one limb per zombie from gunfire (`HasGibbed`).
/// Alive, it just loses it and walks on: *"zombies should not do the animation when they lose a limb, they should just lose it
/// and keep moving"* (2026-09-28). The original's stagger, the model's own dismember clip, is behind `nz_gore react 1`;
/// • AN ARM AT RANDOM, on an explosive kill (moo:4277-4282, "SUPER MEATY") — arms only until legs exist.
/// Never a boss or a special (the original's `IsMooSpecial`), a variant with `CanGib` off, or a model without the bones.
///
/// ⛔ THE HOST DECIDES AND EVERYONE SHOWS. A hit lands on the host: a client's is relayed by `NZNet.HurtRemote`, which carries the
/// body part now (`Health.LastHitPart`). `NZNet.ZombieGib` then tells every client which part went, and the clients only replay it.
/// ⚠️ A CLIENT THAT JOINS LATER sees whole zombies. Nothing is synced, only announced — and a gibbed zombie is dead within seconds.
/// </summary>
public sealed partial class ZombieAI
{
/// <summary>What a body can lose. Flags, because a headless zombie can still lose an arm, as in the original.</summary>
[Flags]
public enum GoreParts
{
None = 0,
Head = 1,
LeftArm = 2,
RightArm = 4,
}
// ── settings (`nz_gore`) ───────────────────────────────────────────────────────────
//
// ⚠️ NULLABLE-BACKED, because a static's default does not survive a hotload (`VoiceRangeScale`'s note).
/// <summary>The whole system, on or off.</summary>
public static bool GoreEnabled { get => _goreEnabled ?? true; set => _goreEnabled = value; }
static bool? _goreEnabled;
/// <summary>
/// Does a zombie that loses an arm while alive stagger through the dismember clip? ⛔ NO BY DEFAULT (2026-09-28): it loses the
/// arm and keeps coming — see the class notes. `nz_gore react 1` brings the stagger back.
/// </summary>
public static bool GoreReacts { get => _goreReacts ?? false; set => _goreReacts = value; }
static bool? _goreReacts;
/// <summary>Health, as a share of the most it had, at or under which a head hit that does not kill takes the head (moo:2315: 10%).</summary>
public static float GoreDecapFraction { get => _goreDecap ?? 0.10f; set => _goreDecap = value; }
static float? _goreDecap;
/// <summary>Spurts a headless zombie lasts before it drops (moo:3296: more than 10, one every 0.15-0.4s — about three seconds).</summary>
public static int GoreBleedSpurts { get => _goreBleedSpurts ?? 10; set => _goreBleedSpurts = value; }
static int? _goreBleedSpurts;
// ── the bones: the original's lists, as subtrees (moo:3809-4019) ────────────────────────────────
/// <summary>
/// The head folds into `j_head` with everything under it (moo:3986, `DeflateBones( "j_head" )` takes the child bones too).
/// The arms fold into their elbows, and the forearm, wrist, twist and fingers under them go too — the original's own list.
/// </summary>
const string GoreHeadBone = "j_head", GoreLeftElbow = "j_elbow_le", GoreRightElbow = "j_elbow_ri";
/// <summary>
/// How small a lost part is drawn: a forearm a foot long ends a hundredth of an inch from its elbow.
/// ⚠️ NOT ZERO. A zero scale is a degenerate matrix, and the original stops at 1e-7 for the same reason.
/// </summary>
const float GoreShrink = 0.001f;
/// <summary>The model's own reactions to losing an arm (moo:7699-7712, the dismember half of Left/RightPainSequences).</summary>
static readonly List<string> GoreLeftArmClips = new() { "nz_pain_dismem_larm_1", "nz_pain_dismem_larm_2", "nz_pain_dismem_larm_3" };
static readonly List<string> GoreRightArmClips = new() { "nz_pain_dismem_rarm_1", "nz_pain_dismem_rarm_2", "nz_pain_dismem_rarm_3" };
/// <summary>A lost part, resolved once per model: where its joint sits on its parent, and every bone to fold.</summary>
sealed class GoreLimb
{
public BoneCollection.Bone Parent;
/// <summary>The joint in its parent bone's space, from the bind pose. Bone lengths do not change, so this holds for every frame.</summary>
public Transform InParent;
/// <summary>Every bone index under the joint, the joint included.</summary>
public int[] Bones;
}
/// <summary>What this body has lost, on this machine.</summary>
public GoreParts Gore { get; private set; }
/// <summary>Lost its head while alive, and bleeding out.</summary>
public bool Headless => _headless;
bool _headless;
GameObject _goreKiller;
int _goreSpurts;
float _goreNextSpurt;
Model _goreModel;
GoreLimb _goreHead, _goreLeftArm, _goreRightArm;
/// <summary>May this zombie lose anything? Walkers only (moo `IsMooSpecial`), and only if the variant allows it.</summary>
bool CanGore => GoreEnabled
&& (Variant?.CanGib ?? true)
&& !(Variant?.IsBoss ?? false)
&& !SpecialEnemies.IsSpecial( Variant );
/// <summary>Has an arm come off already? Gunfire takes one limb per zombie (moo:3678, `!self.HasGibbed`).</summary>
bool GoreHasLostLimb => (Gore & (GoreParts.LeftArm | GoreParts.RightArm)) != 0;
// ── the host: deciding ─────────────────────────────────────────────────────────────
/// <summary>
/// A hit landed and was paid (`_hp.OnDamaged`). The host's copy only, in solo too: a client's copy takes no damage.
///
/// ⚠️ THE KILLING HIT COMES HERE AS WELL, before `Die`. The original's arm test sits in `OnInjured`, which a fatal hit reaches, so an
/// arm shot that kills still takes the arm. The head is left to `GoreOnDeath`.
/// </summary>
void GoreOnHurt( float amount )
{
if ( IsPuppet || State == ZombieState.Dead || !CanGore || !_hp.IsValid() ) return;
var part = _hp.LastHitPart;
var alive = _hp.Current > 0f;
// ⛔ THE REAL HITBOX, NOT THE HEADSHOT FLAG. `headshot` counts Lucky Shot and Wide Bore promotions, and a chest hit promoted to a
// headshot must not pop a head. The original tests where the hit LANDED (within 15 units of the head bone, moo:2316).
if ( part == "head" && alive && (Gore & GoreParts.Head) == 0 )
{
// ⚠️ HEALTH BEFORE THIS HIT. The original reads it in `OnTakeDamage`, before the damage comes off.
var before = _hp.Current + MathF.Max( 0f, amount );
if ( _hp.Max > 0f && before / _hp.Max <= GoreDecapFraction )
GoreLose( GoreParts.Head, alive: true );
return;
}
if ( GoreHasLostLimb ) return;
if ( part == "arm left" ) GoreLose( GoreParts.LeftArm, alive );
else if ( part == "arm right" ) GoreLose( GoreParts.RightArm, alive );
}
/// <summary>The zombie just died (`Die`). The host only: a puppet dies through `DieAsPuppet`, which decides nothing.</summary>
void GoreOnDeath()
{
if ( IsPuppet || !CanGore ) return;
var part = _hp.IsValid() ? _hp.LastHitPart : "";
// moo:4057 (Insta-Kill) and moo:4073 (a killing hit near the head)
if ( (Gore & GoreParts.Head) == 0 && (part == "head" || PowerupEffects.InstaKill) )
GoreLose( GoreParts.Head, alive: false );
// moo:4278 — an explosive kill tears a limb, if none has gone yet (`GibRandom` returns once `HasGibbed`)
if ( part == "explosion" && !GoreHasLostLimb )
GoreLose( Game.Random.Float() < 0.5f ? GoreParts.LeftArm : GoreParts.RightArm, alive: false );
}
/// <summary>
/// The host takes a part: shown here, and announced to every client.
///
/// ⚠️ THE REACTION IS PICKED HERE AND SENT BY NAME, so every machine staggers through the same clip. `PlaySpecial` does not relay
/// on its own, so the name travels with the gib.
/// </summary>
void GoreLose( GoreParts part, bool alive )
{
if ( (Gore & part) != 0 || !GoreRig() || GoreLimbFor( part ) is null ) return;
var clip = alive && part != GoreParts.Head ? GoreReaction( part ) : null;
var bleeding = alive && part == GoreParts.Head;
GoreShow( part, bleeding );
if ( bleeding )
{
_goreKiller = _lastAttacker;
_goreSpurts = 0;
}
NZNet.ZombieGib( GameObject.Id, (int)part, bleeding, clip ?? "" );
}
/// <summary>
/// The model's stagger for a lost arm, rooted as an ability is (`PlaySpecial`), or null when it is busy — or when `GoreReacts` is
/// off, as it is by default.
///
/// ⚠️ ONLY FROM A PLAIN CHASE. A swing, a board being torn, a vault through a window or an entrance each owns the body, and
/// cutting one short to stagger would leave that business half done. The arm still comes off; the zombie just does not flinch.
/// </summary>
string GoreReaction( GoreParts part )
{
if ( !GoreReacts || State != ZombieState.Chasing || ActionPlaying || _vaulting is not null ) return null;
var usable = (part == GoreParts.LeftArm ? GoreLeftArmClips : GoreRightArmClips).Where( ModelHasSequence ).ToList();
if ( usable.Count == 0 ) return null;
var clip = Game.Random.FromList( usable );
if ( !PlaySpecial( clip, 5f ) ) return null;
// ⚠️ HELD FOR ONE PLAY OF THE CLIP, known only once it is playing (`HoldSpecialFor`'s own note)
var length = _renderer.IsValid() ? _renderer.Sequence.Duration : 0f;
HoldSpecialFor( length > 0.05f ? length : 1.2f );
return clip;
}
/// <summary>The host's bleed-out, ten spurts after the head went: the kill goes to whoever took the head.</summary>
void GoreBleedOut()
{
if ( IsPuppet || State == ZombieState.Dead || !_hp.IsValid() ) return;
// ⛔ CREDITED, AND THAT IS ONE DEPARTURE FROM THE ORIGINAL. There the bleed-out is dealt by the world (`Entity(0)`, moo:3298),
// so the player who took the head is paid nothing for the kill. Here `Die` pays it to them as a plain kill, as if the shot had
// finished it. `Die` directly rather than a damage event, so no damage number appears from nowhere and no hit is paid twice.
if ( _goreKiller.IsValid() ) _lastAttacker = _goreKiller;
_lastHitWasMelee = false;
_hp.SetCurrent( 0f );
Die( false );
}
// ── every machine: showing ─────────────────────────────────────────────────────────
/// <summary>
/// A client is told a part went (`NZNet.ZombieGib`). Everything here repeats what the host decided.
/// </summary>
public void GibAsPuppet( int part, bool bleeding, string clip )
{
var p = (GoreParts)part;
if ( (Gore & p) != 0 || !GoreRig() || GoreLimbFor( p ) is null ) return;
GoreShow( p, bleeding && State != ZombieState.Dead );
if ( !string.IsNullOrEmpty( clip ) ) PlayClipAsPuppet( clip, 1f );
}
/// <summary>
/// A joiner is told what this zombie lost before it came (`NZNet.ZombieGore`): folded from the next frame, silently. The burst and
/// the sounds were the moment, and the joiner missed it. ⚠️ NEVER BLEEDING — a headless zombie is seconds from dropping anyway.
/// </summary>
public void GoreFromHost( int parts )
{
if ( !GoreRig() ) return;
foreach ( var p in new[] { GoreParts.Head, GoreParts.LeftArm, GoreParts.RightArm } )
if ( (parts & (int)p) != 0 && GoreLimbFor( p ) is not null ) Gore |= p;
}
/// <summary>The loss itself: the part folds from the next frame, and blood and the gore sounds burst from the joint.</summary>
void GoreShow( GoreParts part, bool bleeding )
{
Gore |= part;
if ( GoreJoint( GoreLimbFor( part ), out var joint, out var outward ) )
{
if ( part == GoreParts.Head )
{
// moo:3986-4002 — the head pop, the gib and the gush together, and blood thrown up from the neck
GoreBurst( joint, Vector3.Up, 36, 190f, 0.85f );
GoreBurst( joint, Vector3.Up, 14, 70f, 1.4f );
NZSound.Play( NZSound.ZombieGoreHead, joint, SoundGate.Feedback );
NZSound.Play( NZSound.ZombieGoreLimb, joint, SoundGate.Feedback );
NZSound.Play( NZSound.ZombieGoreGush, joint, SoundGate.Feedback );
}
else
{
// moo:3838-3847 — the limb gib, and blood out along the upper arm, where the forearm was
GoreBurst( joint, (outward + Vector3.Up * 0.35f).Normal, 24, 150f, 0.7f );
NZSound.Play( NZSound.ZombieGoreLimb, joint, SoundGate.Feedback );
}
}
if ( bleeding )
{
_headless = true;
_goreNextSpurt = Time.Now + Game.Random.Float( 0.15f, 0.4f );
// ⚠️ SILENT FROM HERE (the original's idle and pain sounds all ask `!self:IsDecapitated()`): what was already groaning
// stops, and `TickVoice` starts nothing new. The swing's own cue stays, as it does there — it is the warning.
foreach ( var h in _voices )
if ( h.IsValid() ) h.Stop();
_voices.Clear();
}
}
/// <summary>
/// Every frame, on every machine, from `OnUpdate`: a headless zombie spurts from the neck, and on the host it drops after its
/// last spurt (moo:3283-3299).
/// </summary>
void TickGore()
{
if ( !_headless || State == ZombieState.Dead || Time.Now < _goreNextSpurt ) return;
_goreNextSpurt = Time.Now + Game.Random.Float( 0.15f, 0.4f );
_goreSpurts++;
if ( GoreJoint( _goreHead, out var neck, out _ ) )
GoreBurst( neck, Vector3.Up, 8, 120f, 0.35f );
if ( !IsPuppet && _goreSpurts > GoreBleedSpurts ) GoreBleedOut();
}
/// <summary>
/// After animation, every frame a part is gone: fold it onto its joint.
///
/// ⛔ THE RULES `ModelTuner` PAID FOR, KEPT EXACTLY:
/// • `SetBoneOverride` takes MODEL space ("local coordinates based on the SceneModel's transform"), and the bone getters answer in
/// WORLD — `sm.Transform` converts between them;
/// • an override replaces a bone's FINAL transform, so children do not follow it — every bone of the subtree is written;
/// • a getter answers with the pose AFTER overrides, so an overridden bone is never read back. The joint comes from its PARENT,
/// which is never overridden, plus the joint's bind-pose offset. A read of the folded bone itself would pin the fold to wherever
/// it was on the first frame while the rest of the body walked on.
/// </summary>
protected override void OnPreRender()
{
if ( Gore != GoreParts.None ) ApplyGore();
}
void ApplyGore()
{
if ( !_renderer.IsValid() || !GoreRig() ) return;
var sm = _renderer.SceneModel;
if ( !sm.IsValid() ) return;
var model = sm.Transform;
foreach ( var part in new[] { GoreParts.Head, GoreParts.LeftArm, GoreParts.RightArm } )
{
if ( (Gore & part) == 0 ) continue;
var limb = GoreLimbFor( part );
if ( limb is null || !_renderer.TryGetBoneTransform( limb.Parent, out var parent ) ) continue;
var folded = model.ToLocal( parent.ToWorld( limb.InParent ) ).WithScale( GoreShrink );
foreach ( var i in limb.Bones )
sm.SetBoneOverride( i, in folded );
}
}
/// <summary>Where a part's joint is now, in the world, and which way the lost part pointed out of it.</summary>
bool GoreJoint( GoreLimb limb, out Vector3 joint, out Vector3 outward )
{
joint = WorldPosition + Vector3.Up * 55f;
outward = Vector3.Up;
if ( limb is null || !_renderer.IsValid() || !_renderer.TryGetBoneTransform( limb.Parent, out var parent ) ) return false;
joint = parent.ToWorld( limb.InParent ).Position;
var along = joint - parent.Position;
if ( along.Length > 0.01f ) outward = along.Normal;
return true;
}
GoreLimb GoreLimbFor( GoreParts part ) => part switch
{
GoreParts.Head => _goreHead,
GoreParts.LeftArm => _goreLeftArm,
GoreParts.RightArm => _goreRightArm,
_ => null,
};
/// <summary>Resolve the three parts on this body's model, once per model. False with no model yet.</summary>
bool GoreRig()
{
var model = _renderer.IsValid() ? _renderer.Model : null;
if ( model is null ) return false;
if ( model == _goreModel ) return true;
_goreModel = model;
_goreHead = GoreLimbOf( model, GoreHeadBone );
_goreLeftArm = GoreLimbOf( model, GoreLeftElbow );
_goreRightArm = GoreLimbOf( model, GoreRightElbow );
return true;
}
/// <summary>One part on one model, or null when the model lacks the bone — a hound's skeleton, a boss's.</summary>
static GoreLimb GoreLimbOf( Model model, string root )
{
var bone = model.Bones?.GetBone( root );
if ( bone?.Parent is null ) return null;
var bones = new List<int>();
GoreCollect( bone, bones );
return new GoreLimb
{
Parent = bone.Parent,
InParent = bone.Parent.LocalTransform.ToLocal( bone.LocalTransform ),
Bones = bones.ToArray(),
};
}
static void GoreCollect( BoneCollection.Bone bone, List<int> into )
{
into.Add( bone.Index );
foreach ( var child in bone.Children )
GoreCollect( child, into );
}
// ── blood ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// One shared particle effect for every burst on this machine, in world space, so each spray stays where it was thrown. Made
/// on first use and again after a map change.
///
/// ⚠️ `AshParticles`' RECIPE: the ash mote's sprite, unlit and unfogged so a dark room cannot turn it black, tinted as blood.
/// ⚠️ NO EMITTER. Particles are placed with `Emit` and thrown by hand, so an emitter's default burst cannot fire first.
/// </summary>
static ParticleEffect GoreFx
{
get
{
if ( _goreFx.IsValid() ) return _goreFx;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Zombie Gore";
go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
var fx = go.Components.Create<ParticleEffect>();
fx.MaxParticles = 1200;
fx.LocalSpace = 0f;
fx.Collision = false;
fx.StartVelocity = 0f;
fx.Damping = 0.3f;
// ⚠️ THROWN AND FALLING — gravity, a little heavier than a player's
fx.Force = true;
fx.ForceDirection = Vector3.Down;
fx.ForceScale = 900f;
fx.Lifetime = new ParticleFloat
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Seed,
ConstantA = 0.45f,
ConstantB = 0.95f,
};
fx.ApplyShape = true;
fx.ApplyAlpha = true;
fx.ApplyColor = true;
fx.Tint = GoreBlood;
fx.Scale = new ParticleFloat
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Seed,
ConstantA = 1.1f,
ConstantB = 2.6f,
};
fx.Alpha = new ParticleFloat
{
Type = ParticleFloat.ValueType.Range,
Evaluation = ParticleFloat.EvaluationType.Life,
ConstantA = 1f,
ConstantB = 0f,
};
var renderer = go.Components.Create<ParticleSpriteRenderer>();
renderer.Lighting = false;
renderer.FogStrength = 0f;
renderer.Additive = false;
renderer.Alignment = ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
var sprite = ResourceLibrary.Get<Sprite>( AshParticles.SpritePath );
if ( sprite is null ) Log.Warning( $"[nz-gore] '{AshParticles.SpritePath}' did not load — the blood will be invisible" );
else renderer.Sprite = sprite;
_goreFx = fx;
return fx;
}
}
static ParticleEffect _goreFx;
/// <summary>Dark arterial red. Unlit, so it is a shade lighter than the blood it stands for.</summary>
static readonly Color GoreBlood = new( 0.42f, 0.02f, 0.02f, 1f );
/// <summary>
/// Throw <paramref name="count"/> drops from <paramref name="at"/>, along <paramref name="dir"/> and spread around it.
/// Public since 2026-10-07: the Panzermorder's burst (`PanzermorderBoss.Splatter`) throws its own.
/// </summary>
public static void GoreBurst( Vector3 at, Vector3 dir, int count, float speed, float spread )
{
var fx = GoreFx;
if ( !fx.IsValid() ) return;
for ( var i = 0; i < count; i++ )
{
var p = fx.Emit( at, i / (float)count );
if ( p is null ) return;
p.Velocity = (dir + Vector3.Random * spread).Normal * speed * Game.Random.Float( 0.45f, 1.1f );
}
}
// ── console ────────────────────────────────────────────────────────────────────────
/// <summary>
/// `nz_gore` — the settings, and what the zombies on the map have lost. `nz_gore 0|1` switches it off or on, `nz_gore react 0|1`
/// the stagger when an arm goes, `nz_gore decap 0.1` the head's health share, `nz_gore bleed 10` the spurts before a headless
/// zombie drops.
/// </summary>
[ConCmd( "nz_gore" )]
public static void GoreCmd( string what = "", string value = "" )
{
var on = value is "1" or "on" or "true";
// ⚠️ THE HOST DECIDES EVERY GIB, and a client only replays them (`GibAsPuppet`) — so a setting here changes nothing
if ( !NZGame.IsHost && what.Length > 0 )
Log.Warning( "[nz-gore] the host decides every gib — change it there; on this machine it changes nothing" );
switch ( what.ToLowerInvariant() )
{
case "0" or "off": GoreEnabled = false; break;
case "1" or "on": GoreEnabled = true; break;
case "react" when value.Length > 0: GoreReacts = on; break;
case "decap" when float.TryParse( value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var f ): GoreDecapFraction = Math.Clamp( f, 0f, 1f ); break;
case "bleed" when int.TryParse( value, out var n ): GoreBleedSpurts = Math.Max( 0, n ); break;
case "": break;
default:
Log.Warning( "[nz-gore] nz_gore [0|1] · nz_gore react 0|1 · nz_gore decap 0.1 · nz_gore bleed 10" );
return;
}
var live = All.Where( z => z.IsValid() ).ToList();
Log.Info( $"[nz-gore] {(GoreEnabled ? "ON" : "OFF")} · the arm stagger {(GoreReacts ? "on" : "off")}"
+ $" · a head hit takes the head at {GoreDecapFraction:P0} health or less · headless for {GoreBleedSpurts} spurts" );
Log.Info( $"[nz-gore] {live.Count} zombie(s): {live.Count( z => z.Gore.HasFlag( GoreParts.Head ) )} headless,"
+ $" {live.Count( z => z.GoreHasLostLimb )} one-armed, {live.Count( z => z.Headless && z.State != ZombieState.Dead )} bleeding out"
+ $" · {live.Count( z => !z.CanGore )} that cannot lose anything (bosses, specials)" );
}
/// <summary>
/// `nz_gib [head|larm|rarm]` — take that part from the zombie in front of you (or the nearest), as if shot there, alive: a head
/// walks on headless and bleeds out, an arm comes off (and staggers only with `nz_gore react 1`). Host only. Bare, the head.
/// </summary>
[ConCmd( "nz_gib" )]
public static void GibCmd( string part = "head" )
{
if ( !NZGame.IsHost ) { Log.Warning( "[nz-gore] nz_gib is the host's — the host decides every gib" ); return; }
var which = part.ToLowerInvariant() switch
{
"head" or "" => GoreParts.Head,
"larm" or "left" or "arm" => GoreParts.LeftArm,
"rarm" or "right" => GoreParts.RightArm,
_ => GoreParts.None,
};
if ( which == GoreParts.None ) { Log.Warning( "[nz-gore] nz_gib [head|larm|rarm]" ); return; }
var z = GoreTarget();
if ( !z.IsValid() ) { Log.Warning( "[nz-gore] no living zombie — nz_spawn 1 first" ); return; }
if ( !z.CanGore ) { Log.Warning( $"[nz-gore] '{z.GameObject.Name}' cannot lose anything (boss or special, or CanGib off)" ); return; }
if ( !z.GoreRig() || z.GoreLimbFor( which ) is null ) { Log.Warning( $"[nz-gore] '{z.GameObject.Name}' has no {which} on its skeleton" ); return; }
if ( (z.Gore & which) != 0 ) { Log.Info( $"[nz-gore] '{z.GameObject.Name}' has already lost its {which}" ); return; }
z.GoreLose( which, alive: true );
Log.Info( $"[nz-gore] '{z.GameObject.Name}' lost its {which} · {z.Gore}" );
}
/// <summary>The living zombie closest to the middle of the view within 3000 units, else the nearest one. `nz_dead_eyes test` too.</summary>
internal static ZombieAI GoreTarget()
{
var live = All.Where( z => z.IsValid() && z.State != ZombieState.Dead ).ToList();
if ( live.Count == 0 ) return null;
var cam = Game.ActiveScene?.Camera;
if ( !cam.IsValid() ) return live.First();
var eye = cam.WorldPosition;
var fwd = cam.WorldRotation.Forward;
var inView = live
.Select( z => (z, to: z.WorldPosition + Vector3.Up * 50f - eye) )
.Where( t => t.to.Length < 3000f && Vector3.Dot( t.to.Normal, fwd ) > 0.9f )
.OrderByDescending( t => Vector3.Dot( t.to.Normal, fwd ) )
.Select( t => t.z )
.FirstOrDefault();
return inView ?? live.OrderBy( z => z.WorldPosition.DistanceSquared( eye ) ).First();
}
}