A component that implements the Margwa boss projectile (skull) behavior. It spawns a networked GameObject, renders a skull model and light on each client, homes toward a target, handles collision with world and players, deals damage using the boss's damage values, and can be destroyed by one hit.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// The Shadow Margwa's skulls (2026-10-06), upstream's `nz_ent_proj_chomper`, upstream's numbers:
///
/// | speed | life | steering | on a player | shot |
/// |---|---|---|---|---|
/// | 175 | 8 s | hard (`LerpAngle 0.05` a tick, ≈ 3.4 a second), at chest height | a bite, then gone | ONE hit kills it |
///
/// ⚠️ THE SKULL IS UPSTREAM'S OWN MODEL, the Demon Bow's `chomper_prop.mdl` (same workshop pack), ported by
/// `margwa_port.py chomper` to `margwa_chomper.vmdl`, flying on its own `fly` clip — the user: *"i just want to actually make it
/// have skulls instead of purple balls"*. Its head points +X in model space (measured: tail → head along +X), so it flies
/// jaws first with no turn. Upstream's violet chomper light (120, 80, 255) goes with it.
///
/// ⚠️ THE FIRE MARGWA'S ROLLING WAVE WAS THE OTHER KIND HERE AND IS GONE (2026-10-06): he lays `MargwaFireLine` instead.
/// `Kinds` keeps its one value so the boss's call did not have to change in the same save as this file.
///
/// ⚠️ THE HOST FLIES THEM; EVERYONE ELSE WATCHES. `Launch` network-spawns the object, so its transform replicates and the
/// look is built on every machine in <see cref="OnStart"/>. Damage is the host's, through `Health.Apply` with the boss's
/// name on it, under his cap (`MargwaBoss.MaxHit`).
/// ⚠️ A SKULL CAN BE SHOT ON THE HOST'S MACHINE ONLY for now: a client's bullet meets its own copy, which does nothing.
/// </summary>
public sealed class MargwaProjectile : Component, Component.IDamageable
{
public enum Kinds { Skull }
// ══ tuning ═══════════════════════════════════════════════════════════════
static float? _skullSpeed, _skullLife, _skullTurn, _skullTouch, _skullHit, _skullScale;
/// <summary>175 u/s for 8 s, upstream's.</summary>
public static float SkullSpeed { get => _skullSpeed ?? 175f; set => _skullSpeed = value; }
public static float SkullLife { get => _skullLife ?? 8f; set => _skullLife = value; }
/// <summary>How hard it homes, per second (upstream's 0.05 a tick, ≈ 3.4 a second).</summary>
public static float SkullTurn { get => _skullTurn ?? 3.4f; set => _skullTurn = value; }
public static float SkullTouch { get => _skullTouch ?? 30f; set => _skullTouch = value; }
/// <summary>
/// A bite against one swipe. 0.5 — upstream's 45 is 0.9 of its swipe, and four come at once; at half, all four landing is
/// two swipes, each still under his cap.
/// </summary>
public static float SkullHit { get => _skullHit ?? 0.5f; set => _skullHit = value; }
/// <summary>
/// How big the skull is drawn. 1.6: the model is about 10 units across, upstream's size beside a Margwa at 1; beside ours at
/// 1.25, and flying at you through a fight, 16 reads.
/// </summary>
public static float SkullScale { get => _skullScale ?? 1.6f; set => _skullScale = value; }
public const string SkullModel = "models/zombies/margwa_chomper.vmdl";
// ══ state ════════════════════════════════════════════════════════════════
[Property] public Kinds Kind { get; set; }
/// <summary>What one swipe of his dealt when this left him — sized on the host at launch, so his death mid-flight changes nothing.</summary>
public float SwipeDamage { get; set; }
public GameObject Target { get; set; }
public GameObject Owner { get; set; }
Vector3 _dir;
float _dieAt;
bool _done;
/// <summary>Send one. THE HOST. Returns null on a client, which only watches.</summary>
public static MargwaProjectile Launch( Scene scene, Kinds kind, Vector3 from, Vector3 dir, GameObject target, GameObject owner )
{
if ( !scene.IsValid() || NZGame.IsClient ) return null;
var ai = owner.IsValid() ? owner.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) : null;
int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
var go = new GameObject( true, "nz_margwa_skull" );
go.Flags |= GameObjectFlags.NotSaved;
go.WorldPosition = from;
go.WorldRotation = Rotation.LookAt( dir.Length > 0.01f ? dir.Normal : Vector3.Forward, Vector3.Up );
var p = go.Components.Create<MargwaProjectile>();
p.Kind = kind;
p.Target = target;
p.Owner = owner;
p.SwipeDamage = ZombieStats.AttackDamageForRound( round ) * (ai?.Variant?.DamageMultiplier ?? 2f) * Difficulty.ZombieDamage;
// ⚠️ SOMETHING TO SHOOT: a small solid sphere the bullet trace can meet, so `OnDamage` hears it
var col = go.Components.Create<SphereCollider>();
col.Radius = 9f;
// ⚠️ UNCONDITIONAL, as `ZombieCommands.SpawnAt` does it: a no-op solo, and AFTER the component is configured, since
// the spawn is serialised at this moment.
go.NetworkSpawn();
return p;
}
protected override void OnStart()
{
_dir = WorldRotation.Forward;
_dieAt = Time.Now + SkullLife;
BuildLook();
if ( !NZGame.IsClient ) NZSound.PlayShared( "nz.margwa.shadow.portal", WorldPosition );
}
/// <summary>The look, on every machine — children of a networked object are each machine's own.</summary>
void BuildLook()
{
var lightGo = new GameObject { Parent = GameObject, Name = "light" };
lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
var light = lightGo.Components.Create<PointLight>();
light.LightColor = new Color( 120f / 255f, 80f / 255f, 1f ) * 2.5f; // upstream's chomper light
light.Radius = 180f;
light.Shadows = false;
var skullGo = new GameObject { Parent = GameObject, Name = "skull" };
skullGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
skullGo.LocalScale = SkullScale;
var model = Model.Load( SkullModel );
if ( model is null || model.IsError )
{
Log.Warning( $"[nz-margwa] no '{SkullModel}' — is it compiled? The skull flies as its light alone" );
return;
}
var r = skullGo.Components.Create<SkinnedModelRenderer>();
r.Model = model;
r.UseAnimGraph = false;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
r.Sequence.Name = "fly";
}
protected override void OnUpdate()
{
if ( _done || NZGame.IsClient ) return;
var dt = Time.Delta;
// ── home in on the target, while it is still a target ──
if ( Standing( Target ) )
{
var aim = Target.WorldPosition + Vector3.Up * 50f - WorldPosition;
if ( aim.Length > 1f ) _dir = Vector3.Lerp( _dir, aim.Normal, 1f - MathF.Exp( -SkullTurn * dt ) ).Normal;
}
var from = WorldPosition;
var next = from + _dir * SkullSpeed * dt;
// ── a wall ends it ──
var wall = Scene.Trace.Ray( from, next + _dir * 8f ).IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "zombie", "player", "trigger" ).Run();
if ( wall.Hit )
{
End( wall.HitPosition );
return;
}
WorldPosition = next;
WorldRotation = Rotation.LookAt( _dir, Vector3.Up );
// ── a player in reach ──
foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
if ( (p.WorldPosition + Vector3.Up * 48f).Distance( next ) > SkullTouch ) continue;
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( hp.IsValid() && !hp.IsDead )
hp.Apply( MathF.Min( SwipeDamage * SkullHit, MargwaBoss.MaxHit ), false, Owner.IsValid() ? Owner : GameObject,
blast: true, blastAt: next );
End( next );
return;
}
if ( Time.Now >= _dieAt ) End( next );
}
/// <summary>Is this still somebody to chase — alive, standing.</summary>
static bool Standing( GameObject go )
{
if ( !go.IsValid() ) return false;
var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
return p.IsValid() && !(p.IsDown || p.DownedNet);
}
/// <summary>Gone. THE HOST; destroying a network object ends it everywhere.</summary>
void End( Vector3 at )
{
if ( _done ) return;
_done = true;
NZSound.PlayShared( "nz.margwa.weakspot", at );
GameObject.Destroy();
}
/// <summary>A skull dies to one hit — upstream's `OnTakeDamage` removes it on the first.</summary>
public void OnDamage( in DamageInfo damage )
{
if ( _done || NZGame.IsClient ) return;
End( WorldPosition );
}
/// <summary>`nz_margwa_throw [count]` — the nearest Margwa sends skulls at you now, whatever his skin.</summary>
[ConCmd( "nz_margwa_throw" )]
public static void ThrowCmd( int count = 1 )
{
if ( NZGame.IsClient ) { Log.Info( "[nz-margwa] the host throws" ); return; }
var scene = Game.ActiveScene;
var me = NZPlayer.Local;
var m = scene?.GetAllComponents<MargwaBoss>().OrderBy( b => me.IsValid() ? b.WorldPosition.Distance( me.WorldPosition ) : 0f )
.FirstOrDefault();
if ( !m.IsValid() || !me.IsValid() ) { Log.Info( "[nz-margwa] no Margwa, or no player of yours" ); return; }
for ( int i = 0; i < Math.Clamp( count, 1, 8 ); i++ )
{
var from = m.WorldPosition + Vector3.Up * (110f + i * 14f);
Launch( scene, Kinds.Skull, from, me.WorldPosition + Vector3.Up * 50f - from, me.GameObject, m.GameObject );
}
Log.Info( $"[nz-margwa] sent {Math.Clamp( count, 1, 8 )} skull(s) at you" );
}
}