Zombies/MegatonFx.cs

Two components for the Megaton weapon: MegatonShot implements the projectile (movement, collision with walls/players, spawning visual effects, applying blast damage and daze, playing sounds). MegatonGas implements the gas cloud left by bombs (spawns visuals/sounds, ticks periodic damage to players while avoiding killing them, and destroys itself after a duration).

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// The Megaton's shots (2026-10-07) — upstream's `nz_proj_steiner_blast` (a green ball trailing glow, three to a blast) and
/// `nz_proj_steiner_bomb` (the same ball unseen inside its sparks): straight at <see cref="Speed"/> u/s, no gravity, no
/// homing, through zombies until a wall or a player. Then a <see cref="MegatonBoss.ShotRadius"/>-unit blast: its damage at the
/// middle falling to nothing at the rim (upstream's `50 × (1 − d / 50)`, to the body), and everyone in it dazed (upstream's
/// `NZSonicBlind`, `BossFx.Burst`'s daze); a shot's also goes off in a fireball (upstream's `util.Effect("Explosion")`), a
/// bomb's leaves its gas cloud (<see cref="MegatonGas"/>).
///
/// ⚠️ THE HOST FLIES IT; EVERYONE ELSE WATCHES — `AvogadroBolt`'s pattern: `Launch` network-spawns it with its kind a
/// `[Property]` (the spawn carries the look), every machine builds the light and the sparks, the host moves it and settles it.
/// ⚠️ A <see cref="Life"/> s LIFE: upstream's has none and flies until it meets something. ⚠️ WALLS, DOORS AND PROPS STOP IT:
/// upstream's trigger met only the world's brushes and a player, and went through props and doors.
/// ⛔ NEVER MORE THAN `BossBase.MaxHit` A HIT.
/// </summary>
public sealed class MegatonShot : Component
{
	/// <summary>1750 u/s (upstream's `SetLocalVelocity(dir * 1750)`), for 3 s; a player within 20 of its path is hit.</summary>
	public const float Speed = 1750f, Life = 3f, Touch = 20f;

	/// <summary>The bomb (sparks, the cloud) rather than a shot of the blast (a ball of light, the fireball).</summary>
	[Property] public bool Bomb { get; set; }

	// ── the host's ──
	public GameObject Owner { get; set; }
	/// <summary>Its blast at the middle, and its cloud's tick and burst — sized on the host at launch, so its thrower's death
	/// mid-flight (his split) changes nothing.</summary>
	public float Damage { get; set; }
	public float GasTick { get; set; }
	public float GasBurst { get; set; }

	Vector3 _dir;
	float _dieAt, _nextSpark;
	bool _done;

	/// <summary>Throw one. THE HOST; a client only watches.</summary>
	public static MegatonShot Launch( Scene scene, Vector3 from, Vector3 dir, GameObject owner, float damage, bool bomb, float gasTick,
		float gasBurst )
	{
		if ( !scene.IsValid() || NZGame.IsClient ) return null;

		var go = new GameObject( true, bomb ? "nz_megaton_bomb" : "nz_megaton_shot" );
		go.Flags |= GameObjectFlags.NotSaved;
		go.WorldPosition = from;
		go.WorldRotation = Rotation.LookAt( dir.Length > 0.01f ? dir.Normal : Vector3.Forward, Vector3.Up );

		var s = go.Components.Create<MegatonShot>();
		s.Bomb = bomb;
		s.Owner = owner;
		s.Damage = damage;
		s.GasTick = gasTick;
		s.GasBurst = gasBurst;

		// ⚠️ UNCONDITIONAL (a no-op solo), and after the component is configured, since the spawn is serialised now
		go.NetworkSpawn();
		return s;
	}

	protected override void OnStart()
	{
		_dir = WorldRotation.Forward;
		_dieAt = Time.Now + Life;

		// the look, on every machine — a child of a networked object is each machine's own
		var lightGo = new GameObject { Parent = GameObject, Name = "light" };
		lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		var light = lightGo.Components.Create<PointLight>();
		light.LightColor = MegatonBoss.Acid * (Bomb ? 3f : 4.5f);
		light.Radius = Bomb ? 200f : 150f;
		light.Shadows = false;
	}

	protected override void OnUpdate()
	{
		// ── every machine: sparks in its wake (the blast's trail, upstream's `acidbug_spit_trail_glow`; the bomb's
		// `zmb_radz_bomb_aura`, thicker) ──
		if ( !_done && Time.Now >= _nextSpark )
		{
			_nextSpark = Time.Now + (Bomb ? 0.02f : 0.035f);
			var c = WorldPosition;
			AvogadroSpark.Between( c + Vector3.Random * 4f, c + Vector3.Random.Normal * Game.Random.Float( Bomb ? 10f : 6f, Bomb ? 20f : 12f ),
				Game.Random.Float( 0.06f, 0.14f ), Bomb ? 0.2f : 0.14f, MegatonBoss.Acid );
		}

		if ( _done || NZGame.IsClient ) return;

		var from = WorldPosition;
		var next = from + _dir * Speed * Time.Delta;

		// ── a wall ends it; zombies it passes through, as upstream's trigger does ──
		var trace = Scene.Trace.Ray( from, next ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "player", "trigger", "ragdoll" );
		if ( Owner.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( Owner );
		var wall = trace.Run();
		if ( wall.Hit )
		{
			End( wall.HitPosition - _dir * 4f );
			return;
		}

		// ── a player it passes ──
		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !BossBase.IsUp( p ) || ToBody( p, from, next ) > Touch ) continue;
			End( next );
			return;
		}

		WorldPosition = next;
		if ( Time.Now >= _dieAt ) End( next );
	}

	/// <summary>How near the segment <paramref name="a"/>-<paramref name="b"/> passes a player's body (a line from shin to head).</summary>
	static float ToBody( NZPlayer p, Vector3 a, Vector3 b )
	{
		var foot = p.WorldPosition;
		var best = float.MaxValue;
		for ( int i = 0; i <= 4; i++ )
		{
			var q = Vector3.Lerp( a, b, i / 4f );
			var z = Math.Clamp( q.z, foot.z + 12f, foot.z + 64f );
			best = MathF.Min( best, q.Distance( new Vector3( foot.x, foot.y, z ) ) );
		}
		return best;
	}

	/// <summary>The blast. THE HOST; destroying a network object ends it everywhere.</summary>
	void End( Vector3 at )
	{
		if ( _done ) return;
		_done = true;

		var radius = MegatonBoss.ShotRadius;
		var from = Owner.IsValid() ? Owner : GameObject;
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !BossBase.IsUp( p ) ) continue;
			var d = ToBody( p, at, at );
			if ( d > radius ) continue;

			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;
			hp.Apply( MathF.Min( Damage * (1f - d / radius), BossBase.MaxHit ), false, from, blast: true, blastAt: at );
		}

		// on every machine: the splash (upstream's `acidbug_spit_impact`) and the daze, and the shake (20 within 100)
		BossFx.BurstShared( at, radius, MegatonBoss.Acid, Bomb ? 12 : 8, MegatonBoss.Daze );
		NZNet.ShakeAt( at, 0.3f, 350f );

		if ( Bomb )
		{
			NZSound.PlayShared( "nz.megaton.bomb.explode", at );
			MegatonGas.Lay( Scene, at, Owner, GasTick, GasBurst );
		}
		else
		{
			BlastEffect.Spawn( at, radius );
			NZSound.PlayShared( "nz.megaton.blast.impact", at );
			NZSound.PlayShared( "nz.megaton.blast.cloud", at );
		}

		GameObject.Destroy();
	}
}

/// <summary>
/// The Megaton's gas cloud, where a bomb bursts (2026-10-07) — upstream's `nova_gas_cloud`: for <see cref="MegatonBoss.GasSeconds"/>
/// s everyone up within <see cref="MegatonBoss.GasRadius"/> takes a tick every quarter second (upstream's 1 — 4 a second, 32 at
/// most) but never their last point of health (upstream's `Health() > 1`: the cloud alone downs no one), after a burst as it
/// forms (upstream's `util.BlastDamage(150, 15)`, full at the middle).
///
/// ⚠️ THE HOST'S ALONE AND NOT NETWORKED: its look is every machine's own — the Shadows of Evil Margwa's portal, `PitVisual`'s green
/// fallout pit (`NZNet.MargwaPortal`: a glow, rising gas and a ring at the cloud's reach, gone with it) — and this object only
/// ticks the damage. ⚠️ Upstream's `NZNovaGas` is left out: nothing in the addon reads it.
/// ⛔ NEVER MORE THAN `BossBase.MaxHit` A HIT.
/// </summary>
public sealed class MegatonGas : Component
{
	public GameObject Owner { get; set; }
	public float Tick { get; set; }
	public float Until { get; set; }

	float _next;

	/// <summary>A cloud under <paramref name="at"/>. THE HOST.</summary>
	public static void Lay( Scene scene, Vector3 at, GameObject owner, float tick, float burst )
	{
		if ( !scene.IsValid() || NZGame.IsClient ) return;

		var ground = PitVisual.GroundAt( at );

		// every machine: the cloud, the burst of it (`zmb_gas_explo`'s pale green flash) and its bang
		NZNet.MargwaPortal( ground, MegatonBoss.GasRadius, MegatonBoss.GasSeconds );
		BossFx.BurstShared( ground + Vector3.Up * 6f, MegatonBoss.GasBurstRadius, MegatonBoss.Gas, 14 );
		NZSound.PlayShared( "nz.megaton.gas", ground );
		NZNet.ShakeAt( ground, 0.2f, 450f );

		// the burst as it forms, full at the middle
		var from = owner.IsValid() ? owner : null;
		foreach ( var p in scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !BossBase.IsUp( p ) ) continue;
			var d = (p.WorldPosition + Vector3.Up * 40f).Distance( ground );
			if ( d > MegatonBoss.GasBurstRadius ) continue;

			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;
			hp.Apply( MathF.Min( burst * (1f - d / MegatonBoss.GasBurstRadius), BossBase.MaxHit ), false, from, blast: true,
				blastAt: ground );
		}

		var go = new GameObject( true, "nz_megaton_gas" );
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THE HOST'S OWN: its look went out above
		go.WorldPosition = ground;

		var g = go.Components.Create<MegatonGas>();
		g.Owner = owner;
		g.Tick = tick;
		g.Until = Time.Now + MegatonBoss.GasSeconds;
	}

	protected override void OnUpdate()
	{
		var now = Time.Now;
		if ( now >= Until )
		{
			GameObject.Destroy();
			return;
		}

		if ( now < _next ) return;
		_next = now + 0.25f;

		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !BossBase.IsUp( p ) ) continue;
			var off = p.WorldPosition - WorldPosition;
			if ( off.WithZ( 0 ).Length > MegatonBoss.GasRadius || MathF.Abs( off.z ) > 64f ) continue;

			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			// ⚠️ NEVER THE LAST POINT (upstream's `Health() > 1`)
			var dmg = MathF.Min( MathF.Min( Tick, BossBase.MaxHit ), hp.Current - 1f );
			if ( dmg <= 0f ) continue;
			hp.Apply( dmg, false, Owner.IsValid() ? Owner : null, tick: true, source: "megaton gas" );
		}
	}
}