Zombies/MargwaProjectile.cs

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.

NetworkingFile Access
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" );
	}
}