Zombies/MargwaFx.cs

Visual and gameplay effects for the Margwa (a zombie). Implements a sweep-style fire line that is traced along the ground, spawns flame particle GameObjects and lights, applies host-side damage ticks and requests client-side burns, plus small utility visuals (portal, hide body) and a per-player burn component.

NetworkingFile AccessExternal Download
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The Fire Margwa's attack since 2026-10-06: a LINE of fire racing out along the ground in front of him, burning for a few
/// seconds, hurting whoever stands in it. The user: *"instead of a fire pit that lasts very little time, it makes an entire
/// line in front of it a fire zone, the line has some units of width, and lasts a few seconds, steping on it damages the
/// player"*. It replaces upstream's rolling fire wave (`nz_proj_fire_margwa_firepit`).
///
/// ⚠️ 1.5× AS FAR AND TWICE AS LONG, AND IT BURNS YOU ON (2026-10-06, the user: *"the fire it makes must reach 1.5x further, last
/// 2x longer, and stepping on it not only damages the player, but also does damage over time to the player for 5 seconds,
/// never enough to kill by itself, teh main goal is to make sure the player is unable to regen HP"*): 975 at size 1, 10 s, and
/// every tick in it starts or renews a 5 s burn on that player (<see cref="MargwaBurn"/>).
///
/// ⚠️ THE GROUND IS WALKED, NOT ASSUMED: from the start the line steps along the floor every 25 units, traced down at each
/// step, and stops at a wall or a drop — so it climbs a ramp, follows stairs and never burns in mid-air.
///
/// ⚠️ IT IS DRAWN ON EVERY MACHINE AND BURNS ON THE HOST ONLY: `MargwaBoss` builds the host's copy (<see cref="Deals"/>) and
/// sends `NZNet.MargwaFireLine`, from which each client walks the same floor and builds its own. Damage is the host's,
/// through `Health.Apply` as a tick (no hit immunity, as a fire pool is), with the boss's name on it.
///
/// ⚠️ THE FLAMES ARE THE BURNING ZOMBIE'S OWN (`prefabs/particles/nz/napalm_flame.prefab`, `StatusEffects.ParticlesFor("burn")`),
/// two rows of them, and they light in a sweep from his feet outward at <see cref="Sweep"/> — the flames race away from him,
/// and a spot burns only once the sweep has reached it.
/// </summary>
public sealed class MargwaFireLine : Component
{
	// ══ tuning (nullable-backed, INSTRUCTIONS.md §1) ═══════════════════════════

	static float? _length, _width, _seconds, _sweep, _dps, _burnSeconds, _burnDps;
	/// <summary>How far it reaches, at size 1. 975 — 650 × 1.5, the user's (2026-10-06); 1219 at his 1.25.</summary>
	public static float Length { get => _length ?? 975f; set => _length = value; }
	/// <summary>How wide it is, at size 1. 80.</summary>
	public static float Width { get => _width ?? 80f; set => _width = value; }
	/// <summary>How long it burns. 10 s — twice the 5 it had, the user's (2026-10-06).</summary>
	public static float Seconds { get => _seconds ?? 10f; set => _seconds = value; }
	/// <summary>How fast the flames race out from him, u/s. 1300 — the whole line in half a second.</summary>
	public static float Sweep { get => _sweep ?? 1300f; set => _sweep = value; }
	/// <summary>Damage a second to someone standing in it, against one of his swipes. 0.3.</summary>
	public static float Dps { get => _dps ?? 0.3f; set => _dps = value; }
	/// <summary>How long the burn lasts after the last tick in the fire. 5 s, the user's.</summary>
	public static float BurnSeconds { get => _burnSeconds ?? 5f; set => _burnSeconds = value; }
	/// <summary>
	/// The burn's damage a second, against one of his swipes. 0.08 — its job is to keep regen off, not to kill (it cannot: it
	/// stops at 1 HP); late in a run that is ~29 a second, ~145 over its five.
	/// </summary>
	public static float BurnDps { get => _burnDps ?? 0.08f; set => _burnDps = value; }

	const float Step = 25f;
	const float FlameEvery = 50f;   // ⚠️ 45 before the line grew 1.5×: two rows over 1219 u is still ~50 flames
	const float LightEvery = 170f;
	const float Tick = 0.25f;
	const string FlamePrefab = "prefabs/particles/nz/napalm_flame.prefab";

	/// <summary>Upstream's fire light, 235/75/15.</summary>
	static Color FireColour => new( 235f / 255f, 75f / 255f, 15f / 255f );

	// ══ state ════════════════════════════════════════════════════════════════

	/// <summary>The host's copy burns; every other copy only draws.</summary>
	public bool Deals { get; set; }
	public float SwipeDamage { get; set; }
	public GameObject Source { get; set; }

	readonly List<Vector3> _pts = new();
	readonly List<(GameObject Go, float At)> _flames = new();
	readonly List<(PointLight Light, float At)> _lights = new();
	float _born, _until, _nextTick, _half;
	bool _fading;

	/// <summary>Build a line here, on THIS machine (and its damage, when <paramref name="deals"/>).</summary>
	public static MargwaFireLine Draw( Scene scene, Vector3 start, Vector3 dir, float size, bool deals, float swipeDamage,
		GameObject source )
	{
		if ( !scene.IsValid() ) return null;

		var go = new GameObject( true, "nz_margwa_fireline" );
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen
		go.WorldPosition = start;

		var f = go.Components.Create<MargwaFireLine>();
		f.Deals = deals;
		f.SwipeDamage = swipeDamage;
		f.Source = source;
		f.Build( start, dir.WithZ( 0 ).Length > 0.01f ? dir.WithZ( 0 ).Normal : Vector3.Forward, MathF.Max( 0.1f, size ) );
		return f;
	}

	Vector3? Ground( Vector3 at )
	{
		var tr = Scene.Trace.Ray( at + Vector3.Up * 48f, at + Vector3.Down * 96f )
			.WithoutTags( "zombie", "player", "trigger" ).Run();
		return tr.Hit ? tr.HitPosition : null;
	}

	void Build( Vector3 start, Vector3 dir, float size )
	{
		_born = Time.Now;
		_until = _born + Seconds;
		_half = Width * size * 0.5f;

		// ── walk the floor ──
		var prev = Ground( start ) ?? start;
		_pts.Add( prev );
		var length = Length * size;
		for ( float d = Step; d <= length; d += Step )
		{
			var guess = prev + dir * Step;

			// a wall at knee height ends it
			var wall = Scene.Trace.Ray( prev + Vector3.Up * 24f, guess + Vector3.Up * 24f )
				.WithoutTags( "zombie", "player", "trigger" ).Run();
			if ( wall.Hit ) break;

			// so does a drop or a step too tall to be one floor
			var g = Ground( guess );
			if ( g is null || MathF.Abs( g.Value.z - prev.z ) > 40f ) break;

			_pts.Add( g.Value );
			prev = g.Value;
		}

		var right = Vector3.Cross( dir, Vector3.Up ).Normal;
		var reach = (_pts.Count - 1) * Step;

		// ── the flames: two rows across the width, lit in a sweep ──
		var rows = _half * 2f >= 60f ? new[] { -0.5f, 0.5f } : new[] { 0f };
		for ( float d = 0f; d <= reach + 0.01f; d += FlameEvery )
		{
			var p = PointAt( d );
			foreach ( var row in rows )
			{
				var at = p + right * (row * _half);
				var floor = Ground( at ) ?? at;
				GameObject flame = null;
				try { flame = GameObject.Clone( FlamePrefab, new Transform( floor ) ); }
				catch ( Exception e ) { Log.Warning( $"[nz-margwa] fire line: '{FlamePrefab}' would not clone: {e.Message}" ); }
				if ( !flame.IsValid() ) continue;

				flame.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
				flame.SetParent( GameObject );
				flame.WorldScale = 1.3f * size;
				flame.Enabled = false;
				_flames.Add( (flame, _born + d / MathF.Max( 1f, Sweep )) );
			}
		}

		// ── the light: a few along the line, upstream's colour ──
		for ( float d = 0f; d <= reach + 0.01f; d += LightEvery )
		{
			var lgo = new GameObject { Parent = GameObject, Name = "light" };
			lgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
			lgo.WorldPosition = PointAt( d ) + Vector3.Up * 30f;
			var l = lgo.Components.Create<PointLight>();
			l.LightColor = Color.Black;
			l.Radius = 190f * size;
			l.Shadows = false;
			_lights.Add( (l, _born + d / MathF.Max( 1f, Sweep )) );
		}
	}

	/// <summary>The floor point <paramref name="d"/> units along the line.</summary>
	Vector3 PointAt( float d )
	{
		if ( _pts.Count == 0 ) return WorldPosition;
		var f = MathF.Max( 0f, d ) / Step;
		var i = Math.Min( (int)f, _pts.Count - 1 );
		if ( i >= _pts.Count - 1 ) return _pts[^1];
		return Vector3.Lerp( _pts[i], _pts[i + 1], f - i );
	}

	protected override void OnUpdate()
	{
		var now = Time.Now;

		foreach ( var (go, at) in _flames )
			if ( go.IsValid() && !go.Enabled && !_fading && now >= at ) go.Enabled = true;

		// the light: up as the sweep passes, flickering, eased out at the end
		var left = Math.Clamp( (_until - now) / 1f, 0f, 1f );
		var flick = 0.85f + 0.15f * MathF.Sin( now * 23f ) * MathF.Sin( now * 7.3f );
		foreach ( var (l, at) in _lights )
			if ( l.IsValid() ) l.LightColor = now >= at ? FireColour * (2.6f * flick * left * left) : Color.Black;

		if ( !_fading && now >= _until )
		{
			// ⚠️ THE EMITTERS STOP, THE FLAMES ALREADY UP FINISH (under a second), so it dies down rather than blinking out
			_fading = true;
			foreach ( var (go, _) in _flames )
				if ( go.IsValid() )
					foreach ( var e in go.Components.GetAll<ParticleEmitter>( FindMode.EverythingInSelfAndDescendants ) )
						e.Enabled = false;
		}

		if ( now >= _until + 1.2f )
		{
			GameObject.Destroy();
			return;
		}

		if ( Deals && !_fading && now >= _nextTick )
		{
			_nextTick = now + Tick;
			Burn( now );
		}
	}

	/// <summary>Everyone standing in the lit part of the line takes a tick. THE HOST.</summary>
	void Burn( float now )
	{
		if ( _pts.Count < 2 || SwipeDamage <= 0f ) return;

		var lit = (now - _born) * Sweep;
		var damage = SwipeDamage * MathF.Max( 0f, Dps ) * Tick;

		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
			if ( !Inside( p.WorldPosition, lit ) ) continue;

			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( hp.IsValid() && !hp.IsDead )
				hp.Apply( damage, false, Source.IsValid() ? Source : GameObject, tick: true );

			// ⚠️ AND IT CATCHES: every tick in the fire starts or renews the 5 s burn, on that player's own machine
			NZNet.MargwaBurn( p.GameObject.Id, BurnSeconds, SwipeDamage * MathF.Max( 0f, BurnDps ) );
		}
	}

	/// <summary>Is a pair of feet on the burning stretch: within half the width of the line, and on its floor.</summary>
	bool Inside( Vector3 feet, float lit )
	{
		for ( int i = 0; i < _pts.Count - 1; i++ )
		{
			if ( i * Step > lit ) break;

			var a = _pts[i];
			var b = _pts[i + 1];
			var ab = (b - a).WithZ( 0 );
			var t = ab.LengthSquared < 0.01f ? 0f : Math.Clamp( Vector3.Dot( (feet - a).WithZ( 0 ), ab ) / ab.LengthSquared, 0f, 1f );
			var on = Vector3.Lerp( a, b, t );

			if ( (feet - on).WithZ( 0 ).Length <= _half + 10f && MathF.Abs( feet.z - on.z ) <= 40f ) return true;
		}

		return false;
	}
}

/// <summary>
/// The Fire Margwa's burn on a player (2026-10-06): a tick every half second for <see cref="MargwaFireLine.BurnSeconds"/> after
/// the last step in the fire, never the kill — it stops at 1 HP — and no regeneration while it lasts.
///
/// ⚠️ ON THE OWNER'S MACHINE, ON THE PLAYER'S OWN BODY. The host sees who stands in the fire and sends `NZNet.MargwaBurn`; the
/// owner keeps the clock and takes the ticks from its own health, so "never the kill" is judged on the real number and not on
/// the host's copy (which has never heard of a client's Juggernog — `Health.Apply`'s relay note), and holds its own regen
/// off (`Health.NoRegenUntil`), since `HealthRegen` runs there too.
/// ⚠️ THROUGH ARMOR: a burn is flesh, and plates soaking it would leave the regen block as its only effect.
/// </summary>
public sealed class MargwaBurn : Component
{
	public float Until { get; set; }
	public float Dps { get; set; }

	float _nextTick;
	const float Tick = 0.5f;

	/// <summary>Is this machine's player burning, and for how long yet.</summary>
	public static float LeftOnMe
	{
		get
		{
			var me = NZPlayer.Local;
			var b = me.IsValid() ? me.Components.Get<MargwaBurn>( FindMode.EverythingInSelf ) : null;
			return b.IsValid() ? MathF.Max( 0f, b.Until - Time.Now ) : 0f;
		}
	}

	/// <summary>THIS machine's own player catches fire, or keeps burning: its clock runs to now + seconds.</summary>
	public static void Catch( float seconds, float dps )
	{
		var me = NZPlayer.Local;
		if ( !me.IsValid() || me.IsDown ) return;

		var b = me.Components.GetOrCreate<MargwaBurn>();
		b.Until = MathF.Max( b.Until, Time.Now + MathF.Max( 0f, seconds ) );
		b.Dps = MathF.Max( 0f, dps );
	}

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

		var me = Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
		var hp = me.IsValid() ? me.Hp : null;
		if ( !hp.IsValid() || me.IsDown ) { Until = 0f; return; }

		// regen waits out the burn, and the screen burns while it lasts — the napalm zombie's overlay
		if ( hp.NoRegenUntil < Until ) hp.NoRegenUntil = Until;

		// ⛔ AT FULL, NOT 0.4 (2026-10-06, the user: *"they must get teh fire overlay the napalm zombie gives on proximity, for as
		// long as the damage over time lasts"*). `NapalmOverlay` draws its flame texture only above 0.3 and ramps it to 1: at 0.4
		// the flames were ~3% opaque, a faint orange wash and no fire. 1 is a napalm zombie on top of you.
		NapalmZombie.RaiseHeat( 1f );

		if ( now < _nextTick ) return;
		_nextTick = now + Tick;

		// ⛔ NEVER THE KILL: it stops at 1, and from there it only holds regen off
		var dmg = MathF.Min( Dps * Tick, hp.Current - 1f );
		if ( dmg > 0.01f ) hp.Apply( dmg, false, null, ignoreArmor: true, tick: true, source: "margwa burn" );
	}
}

/// <summary>Small Margwa visuals every machine draws for itself (2026-10-06).</summary>
public static class MargwaFx
{
	/// <summary>
	/// A teleport portal on the floor, on THIS machine: `PitVisual`'s green `Fallout` look — the glow, the gas and the ring —
	/// for <paramref name="seconds"/>. The Shadows of Evil Margwa's way in and out (`MargwaBoss`, `NZNet.MargwaPortal`).
	/// </summary>
	public static void DrawPortal( Scene scene, Vector3 at, float radius, float seconds )
	{
		if ( !scene.IsValid() ) return;

		var go = new GameObject( true, "nz_margwa_portal" );
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen
		go.WorldPosition = at;

		PitVisual.Attach( go, radius, PitVisual.Style.Fallout, seconds );

		// ⚠️ A PIT VISUAL DOES NOT REMOVE ITSELF — its owner does (the Tar Pit, the fallout) — so this one gets an owner
		go.Components.Create<MargwaExpire>().At = Time.Now + seconds + 0.5f;
	}

	/// <summary>
	/// Hide or show a body on THIS machine (`NZNet.MargwaVanish`).
	///
	/// ⛔ `RenderType`, NOT `Enabled`: a disabled renderer throws from its sequence accessor, and `ZombieAI` reads it every
	/// frame (INSTRUCTIONS.md, "A sequence accessor throws on a DISABLED renderer").
	/// </summary>
	public static void SetHidden( GameObject body, bool hidden )
	{
		if ( !body.IsValid() ) return;

		foreach ( var r in body.Components.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
			r.RenderType = hidden ? ModelRenderer.ShadowRenderType.ShadowsOnly : ModelRenderer.ShadowRenderType.On;
	}
}

/// <summary>Removes its object at <see cref="At"/>. For the Margwa's local visuals, which nothing else owns.</summary>
public sealed class MargwaExpire : Component
{
	public float At { get; set; }

	protected override void OnUpdate()
	{
		if ( Time.Now >= At ) GameObject.Destroy();
	}
}