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.
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();
}
}