UI/ZombieHealthBars.cs

Client-side HUD component that composes data for a single floating zombie health bar (Bo6-style). It tracks which zombie to show (aimed or last hit), animates a follower bar, rising damage chunks, name, icon and optional damage text, and emits a frame list of simple drawing primitives for the UI layer to render.

File AccessNative Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// ZOMBIE HEALTH BARS: Black Ops 6's floating bar over the zombie you aim at or just hit (2026-10-06). Ported from Froze's "Black
/// Ops 6 Zombies Healthbars" (Workshop 3368435414; the original is in `Sbox nzombies/Staging/bo6healthbar_3368435414`). The user:
/// *"zombie health bars … extract this addon, we will port it basically"*.
///
/// What it draws, as the addon's HUDPaint does (`cl_bo6healthbar.lua`):
/// - a thin red bar over the head, sized from the screen (7.4% wide, 0.83% tall), on a dark grey ground or the addon's own
///   texture (`nz_hbar_altbg 1`), outlined in black, with a black tick where the health ends;
/// - a white FOLLOWER behind the red: it holds where the health was for a second after the first hit, then slides down;
/// - each hit's chunk rising out of the bar and fading, yellow for a headshot of yours;
/// - the enemy's name under it in capitals, Call of Duty's Hitmarker face with a soft halo (`nz_hbar_name 0` hides it), and the
///   addon's skull to its left, the tall one for a boss;
/// - the damage of the last second beside it, off by default as in the addon (`nz_hbar_damage 1`).
///
/// ⚠️ ONE BAR AT A TIME, the addon's rule: the zombie under your crosshair within `nz_hbar_distance`, else the one you last hit;
/// three seconds aiming at nothing hides it. A bar that comes back after a while away starts from the health it finds, so damage
/// dealt out of sight does not arrive as one huge chunk.
///
/// ⛔ IT DRAWS NOTHING ITSELF: each update it writes what the bar is this frame (<see cref="Frame"/>: boxes, pictures, lines of
/// text, in screen pixels and drawing order), the addon's own `surface` calls near enough one for one, and `ZombieHealthBarsHud`
/// puts that on screen as pooled panels, the way `DamageNumbersHud` and `PlayerTagsHud` draw over the world. The camera's painter
/// (`BeginHud`) was tried first, in Update and then in PreRender, and showed nothing either time (2026-10-06).
///
/// ⚠️ EVERY MACHINE DRAWS ITS OWN. A client reads the host's health (`ZombieAI.HealthNow`, synced as `HealthNet`); the hits that
/// pick the bar and colour a chunk are this machine's own (`NoteHit`, from `DamageNumbers.Report`, which is given exactly those).
/// </summary>
public sealed class ZombieHealthBars : Component
{
	// ══ settings (saved per player) ══════════════════════════════════════════════════

	/// <summary>`nz_hbar 0` turns the bars off, for this player.</summary>
	[ConVar( "nz_hbar", Saved = true, Help = "Zombie health bars (Black Ops 6 style): 1 on, 0 off" )]
	public static bool On { get; set; } = true;

	/// <summary>`nz_hbar_damage 1`: the damage of the last second beside the bar. Off by default, as in the addon.</summary>
	[ConVar( "nz_hbar_damage", Saved = true, Help = "Show the damage dealt in the last second beside the health bar" )]
	public static bool ShowDamage { get; set; } = false;

	/// <summary>`nz_hbar_name 0` hides the enemy's name.</summary>
	[ConVar( "nz_hbar_name", Saved = true, Help = "Show the enemy's name under the health bar" )]
	public static bool ShowName { get; set; } = true;

	/// <summary>`nz_hbar_altbg 1`: the addon's textured ground instead of the flat grey.</summary>
	[ConVar( "nz_hbar_altbg", Saved = true, Help = "Use the textured background behind the health bar" )]
	public static bool AltGround { get; set; } = false;

	/// <summary>How far a zombie you aim at may be for its bar to show, in units. The addon's 3000.</summary>
	[ConVar( "nz_hbar_distance", Saved = true, Help = "How far away (units) a zombie's health bar shows when you aim at it" )]
	public static float MaxDistance { get; set; } = 3000f;

	// ══ the addon's look ════════════════════════════════════════════════════════════

	// ⚠️ PROPERTIES, NOT `static readonly` FIELDS (INSTRUCTIONS §1): a retuned colour reaches a running editor on the next hotload
	static Color Red => new( 221 / 255f, 71 / 255f, 59 / 255f );
	static Color Ground => new( 69 / 255f, 73 / 255f, 76 / 255f );
	static Color BodyChunk => new( 1f, 1f, 1f );
	static Color HeadChunk => new( 1f, 1f, 94 / 255f );

	/// <summary>Seconds the follower holds after a burst's first hit, before it slides.</summary>
	const float HoldSeconds = 1f;

	/// <summary>Seconds aiming at nothing before the bar goes.</summary>
	const float HideAfter = 3f;

	/// <summary>Seconds a dead zombie's bar stays, at its last place.</summary>
	const float DeathLinger = 0.4f;

	/// <summary>The addon's pictures, as the panels load them.</summary>
	const string IconNormalPath = "ui/healthbar/icon_normal.png";
	const string IconBossPath = "ui/healthbar/icon_boss.png";
	const string AltGroundPath = "ui/healthbar/bg_alt_1.png";

	// ══ what to draw this frame ═════════════════════════════════════════════════════

	/// <summary>One thing to draw, in screen pixels: a filled box, a picture, or a line of text.</summary>
	public struct Prim
	{
		/// <summary>0 a box, 1 a picture, 2 a line of text.</summary>
		public int Kind;
		public Rect Rect;
		public Color Color;
		/// <summary>A picture's path.</summary>
		public string Image;
		/// <summary>A text's words, and its look: "name" or "damage" (`ZombieHealthBarsHud.razor.scss`).</summary>
		public string Text, Look;
		/// <summary>A text's size, in screen pixels.</summary>
		public float Size;
	}

	// ⚠️ NULLABLE-BACKED (INSTRUCTIONS §1)
	static List<Prim> _frame;

	/// <summary>
	/// The bar as it is this frame, in drawing order, for `ZombieHealthBarsHud`: emptied at the top of every update, so nothing
	/// shown leaves nothing on screen.
	/// </summary>
	public static List<Prim> Frame => _frame ??= new List<Prim>();

	static void Box( float x, float y, float width, float height, Color color )
	{
		if ( width <= 0f || height <= 0f || color.a <= 0f ) return;
		Frame.Add( new Prim { Kind = 0, Rect = new Rect( x, y, width, height ), Color = color } );
	}

	static void Picture( string path, Rect rect )
		=> Frame.Add( new Prim { Kind = 1, Rect = rect, Image = path, Color = Color.White } );

	static void Words( string text, string look, float size, Vector2 at )
		=> Frame.Add( new Prim { Kind = 2, Rect = new Rect( at.x, at.y, size * 40f, size * 2f ), Text = text, Look = look, Size = size, Color = Color.White } );

	// ══ the hits of mine (`DamageNumbers.Report`) ═══════════════════════════════════

	static ZombieAI _hitZombie;
	static bool _hitHead;
	static float _hitAt = -100f;
	static int _hitCount;

	/// <summary>
	/// A hit of this machine's on something with health: from `DamageNumbers.Report`, which is called for exactly those (a client's
	/// own relayed hits, and on the host everything but another player's). A zombie's makes its bar the one shown, and a headshot
	/// colours the chunk it takes off.
	/// </summary>
	public static void NoteHit( Health victim, bool headshot )
	{
		if ( !victim.IsValid() ) return;

		var z = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelf );
		if ( !z.IsValid() ) return;

		// ⚠️ A SHOTGUN'S PELLETS ARE ONE SHOT: the same zombie in the same frame keeps any head pellet's colour
		var now = RealTime.Now;
		_hitHead = z == _hitZombie && now - _hitAt < 0.001f ? _hitHead || headshot : headshot;
		_hitZombie = z;
		_hitAt = now;
		_hitCount++;
	}

	// ══ the bars ═══════════════════════════════════════════════════════════════════

	/// <summary>One zombie's bar as it animates: its follower, its hold, its chunks.</summary>
	sealed class Bar
	{
		public float LastHp = -1f;
		public float Follower = 1f;
		public bool Holding, Following;
		public float HoldUntil;
		public float DamageTotal;
		public Vector3 LastTop;
		public float DeadFor;
		public float LastSeen = -100f;
		public readonly List<Chunk> Chunks = new();
	}

	/// <summary>One hit's chunk, rising out of the bar. X and W are shares of the bar; H and Line are pixels; A and A2 0..1.</summary>
	sealed class Chunk
	{
		public float X, W, H, Line, A, A2;
		public bool Head, Front;
	}

	readonly Dictionary<ZombieAI, Bar> _bars = new();
	ZombieAI _shown;
	float _sinceTarget;
	int _hitsSeen;
	bool _drewOnce;

	protected override void OnUpdate()
	{
		Frame.Clear();
		if ( !On ) return;

		// ⚠️ THE SURVIVAL HUD'S RULE: nothing in the lobby or under its menu, where a HUD reads as a bug
		if ( NZGame.Mode == GameMode.Lobby || LobbyState.IsOpen?.Invoke() == true ) return;

		var cam = Scene.Camera;
		if ( !cam.IsValid() ) return;

		var now = RealTime.Now;
		var dt = RealTime.Delta;

		PickTarget( cam, dt );
		Prune( now );

		if ( _shown is null || !_bars.TryGetValue( _shown, out var bar ) ) return;

		Step( _shown, bar, now, dt );
		if ( _shown is not null && _bars.TryGetValue( _shown, out bar ) ) Compose( cam, _shown, bar );
	}

	/// <summary>The zombie under the crosshair, or the one I last hit; nothing for three seconds hides the bar.</summary>
	void PickTarget( CameraComponent cam, float dt )
	{
		var aimed = Aimed( cam );
		if ( aimed is not null )
		{
			Show( aimed );
			_sinceTarget = 0f;
		}
		else
		{
			_sinceTarget += dt;
		}

		if ( _hitCount != _hitsSeen )
		{
			_hitsSeen = _hitCount;
			if ( _hitZombie.IsValid() && _hitZombie.State != ZombieState.Dead
				&& _hitZombie.WorldPosition.Distance( cam.WorldPosition ) <= MaxDistance )
			{
				Show( _hitZombie );
				_sinceTarget = 0f;
			}
		}

		// ⚠️ A DEAD ZOMBIE'S BAR IS LEFT TO LINGER (`Step`), not taken away here
		if ( _shown is not null && _sinceTarget >= HideAfter && Alive( _shown ) ) _shown = null;
	}

	static bool Alive( ZombieAI z ) => z.IsValid() && z.State != ZombieState.Dead;

	/// <summary>The living zombie the camera looks at within reach, or null.</summary>
	ZombieAI Aimed( CameraComponent cam )
	{
		var from = cam.WorldPosition;
		var trace = Scene.Trace.Ray( from, from + cam.WorldRotation.Forward * MathF.Max( 1f, MaxDistance ) ).UseHitboxes( true );

		var me = NZPlayer.Local;
		if ( me.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( me.GameObject );

		var tr = trace.Run();
		if ( !tr.Hit || !tr.GameObject.IsValid() ) return null;

		var root = ZombieAI.RootOf( tr.GameObject );
		var z = root.IsValid() ? root.Components.Get<ZombieAI>( FindMode.EverythingInSelf ) : null;
		return Alive( z ) ? z : null;
	}

	void Show( ZombieAI z )
	{
		// ⚠️ A DYING BAR KEEPS THE SCREEN FOR ITS LINGER, as the addon pauses its trace on a kill: the next zombie behind it waits
		if ( _shown is not null && _shown != z && !Alive( _shown ) && _bars.TryGetValue( _shown, out var dying ) && dying.DeadFor < DeathLinger )
			return;

		_shown = z;
		if ( !_bars.ContainsKey( z ) ) _bars[z] = new Bar();
	}

	/// <summary>Bars of zombies gone and unshown, dropped; and any not seen for ten seconds.</summary>
	void Prune( float now )
	{
		if ( _bars.Count == 0 ) return;

		List<ZombieAI> drop = null;
		foreach ( var (z, b) in _bars )
		{
			if ( z == _shown ) continue;
			if ( !z.IsValid() || now - b.LastSeen > 10f ) (drop ??= new List<ZombieAI>()).Add( z );
		}

		if ( drop is null ) return;
		foreach ( var z in drop ) _bars.Remove( z );
	}

	/// <summary>One frame of the shown bar: a new chunk for every drop in health, the follower's hold and slide, the chunks' rise.</summary>
	void Step( ZombieAI z, Bar bar, float now, float dt )
	{
		var alive = Alive( z );
		var max = z.IsValid() ? z.HealthMax : 0f;
		var hp = alive ? MathF.Max( 0f, z.HealthNow ) : 0f;

		// ⚠️ BACK AFTER A WHILE AWAY: start from what is there now
		if ( now - bar.LastSeen > 0.5f || bar.LastHp < 0f )
		{
			bar.LastHp = hp;
			bar.Follower = max > 0f ? Math.Clamp( hp / max, 0f, 1f ) : 1f;
			bar.Holding = bar.Following = false;
			bar.DamageTotal = 0f;
			bar.Chunks.Clear();
		}

		bar.LastSeen = now;

		if ( max > 0f && hp < bar.LastHp - 0.01f )
		{
			var dealt = MathF.Min( bar.LastHp, bar.LastHp - hp );

			// ⚠️ THE FIRST HIT OF A BURST GETS ITS FRONT EDGE AND STARTS THE HOLD; THE REST RIDE THE FOLLOWER, as in the addon
			var front = !bar.Holding && !bar.Following;
			if ( front )
			{
				bar.Holding = true;
				bar.HoldUntil = now + HoldSeconds;
			}

			var head = z == _hitZombie && now - _hitAt < 0.25f && _hitHead;
			bar.Chunks.Add( new Chunk
			{
				X = Math.Clamp( hp / max, 0f, 1f ),
				W = Math.Clamp( dealt / max, 0f, 1f ),
				H = 0f,
				Line = 0f,
				A = 100f / 255f,
				A2 = 140f / 255f,
				Head = head,
				Front = front,
			} );

			if ( ShowDamage ) bar.DamageTotal += dealt;
		}

		bar.LastHp = hp;

		if ( bar.Holding && now >= bar.HoldUntil )
		{
			bar.Holding = false;
			bar.Following = true;
			bar.DamageTotal = 0f;
		}

		var share = max > 0f ? Math.Clamp( hp / max, 0f, 1f ) : 0f;
		if ( !bar.Holding ) bar.Follower = MathX.Lerp( bar.Follower, share, MathF.Min( 1f, dt / 0.11f ) );
		if ( share * 1.03f > bar.Follower )
		{
			bar.Follower = share;
			bar.Following = false;
		}

		// ⚠️ THE CHUNKS RISE TO THE BAR'S HEIGHT PLUS A LITTLE, FADE NEAR THE TOP, AND GO WHEN CLEAR (the addon's constants)
		var rise = BarHeight() + Screen.Height * 0.0111f;
		for ( var i = bar.Chunks.Count - 1; i >= 0; i-- )
		{
			var c = bar.Chunks[i];
			c.H = MathX.Lerp( c.H, rise, MathF.Min( 1f, dt / 0.16f ) );
			c.Line = MathX.Lerp( c.Line, rise, MathF.Min( 1f, dt / 0.08f ) );
			if ( c.H > 0.95f * rise ) c.A = MathX.Lerp( c.A, 0f, MathF.Min( 1f, dt / 0.15f ) );
			if ( c.Line > 0.95f * rise ) c.A2 = MathX.Lerp( c.A2, 0f, MathF.Min( 1f, dt / 0.1f ) );
			if ( c.A < 0.004f ) bar.Chunks.RemoveAt( i );
		}

		if ( alive )
		{
			bar.DeadFor = 0f;
			return;
		}

		bar.DeadFor += dt;
		if ( bar.DeadFor >= DeathLinger )
		{
			_bars.Remove( z );
			_shown = null;
		}
	}

	static float BarHeight() => MathF.Max( 3f, MathF.Round( Screen.Height * 0.00833f ) );

	/// <summary>The bar, its name, its icon, its damage, then its chunks over all of it: the addon's drawing order.</summary>
	void Compose( CameraComponent cam, ZombieAI z, Bar bar )
	{
		var w = Screen.Width;
		var h = Screen.Height;

		// ⚠️ OVER THE HEAD, from the body's own height (`BodyHeight`, which a variant sets); the last place once it is gone
		if ( Alive( z ) ) bar.LastTop = z.WorldPosition + Vector3.Up * (z.BodyHeight + 2f);

		var screen = cam.PointToScreenPixels( bar.LastTop, out var behind );
		if ( behind ) return;

		var bw = MathF.Round( w * 0.07395833f );
		var bh = BarHeight();
		var bx = MathF.Round( screen.x - bw * 0.5f );
		var by = MathF.Round( screen.y - bh * 0.5f - h * 0.02f );

		var max = z.IsValid() ? z.HealthMax : 0f;
		var share = Alive( z ) && max > 0f ? Math.Clamp( z.HealthNow / max, 0f, 1f ) : 0f;
		var hw = MathF.Round( bw * share );

		// ── the bar: ground, follower, health, the tick where it ends, a black outline ──
		if ( AltGround ) Picture( AltGroundPath, new Rect( bx, by, bw, bh ) );
		else Box( bx, by, bw, bh, Ground );

		Box( bx, by, MathF.Round( bw * bar.Follower ), bh, Color.White );
		Box( bx, by, hw, bh, Red );
		if ( hw < bw ) Box( bx + hw - 1f, by, 1f, bh, Color.Black );

		Box( bx, by, bw, 1f, Color.Black );
		Box( bx, by + bh - 1f, bw, 1f, Color.Black );
		Box( bx, by, 1f, bh, Color.Black );
		Box( bx + bw - 1f, by, 1f, bh, Color.Black );

		// ── its name, in capitals, under it ──
		if ( ShowName ) Words( NameOf( z ).ToUpperInvariant(), "name", h * 0.014f, new Vector2( bx, by + bh + 2f ) );

		// ── the skull, the tall one for a boss ──
		var boss = z.IsValid() && z.Variant?.IsBoss == true;
		var iw = boss ? w * 0.0138f : w * 0.0125f;
		var ih = boss ? h * 0.0352f : w * 0.0125f;
		Picture( boss ? IconBossPath : IconNormalPath,
			new Rect( MathF.Round( bx - iw - w * 0.00078125f ), MathF.Round( by - h * 0.00463f ), MathF.Round( iw ), MathF.Round( ih ) ) );

		// ── the damage of the last second ──
		if ( ShowDamage && bar.DamageTotal > 0f )
			Words( DamageNumbers.Compact( bar.DamageTotal ), "damage", h * 0.016f, new Vector2( bx + bw + 3f, by - h * 0.016f * 0.5f ) );

		// ── the chunks, rising out of it ──
		var followEnd = bx + bw * bar.Follower;
		foreach ( var c in bar.Chunks )
		{
			var tint = c.Head ? HeadChunk : BodyChunk;
			var x = MathF.Max( bx, MathF.Round( bx + bw * c.X ) );
			var cw = MathF.Max( 1f, MathF.Round( bw * c.W ) );
			var lineColor = tint.WithAlpha( c.A2 );
			var lineTop = by + bh - c.Line - 1f;

			// ⚠️ THE ADDON'S TWO 1px EDGE LINES AS ONE 2px BOX, a box fewer per chunk
			Box( x, lineTop, 2f, c.Line, lineColor );

			var widen = 0f;
			if ( c.Front ) Box( x + cw - 2f, lineTop, 2f, c.Line, lineColor );
			else widen = 1f;

			Box( x, by + bh - c.H - 1f, cw + widen, c.H, tint.WithAlpha( c.A ) );

			// ⚠️ AND THE CHUNK INSIDE THE BAR, SOLID, CUT TO THE FOLLOWER (the addon's scissor rect, worked out here)
			var x0 = MathF.Max( x, bx );
			var x1 = MathF.Min( x + cw, followEnd );
			if ( x1 > x0 ) Box( x0, by + 1f, x1 - x0, bh - 2f, tint );
		}

		if ( !_drewOnce )
		{
			_drewOnce = true;
			Log.Info( $"[nz-hbar] first bar composed — {NameOf( z )} at {screen.x:0},{screen.y:0} · {bw:0}x{bh:0}px · {Frame.Count} piece(s)"
				+ " for ZombieHealthBarsHud · nz_hbar_info for more" );
		}
	}

	/// <summary>
	/// What the bar calls a zombie: a special or a boss by its own name (`SpecialEnemies`), every walker "Zombie" whatever map's
	/// skin it wears.
	/// </summary>
	static string NameOf( ZombieAI z )
	{
		var v = z.IsValid() ? z.Variant : null;
		if ( v is null ) return "Zombie";

		var path = v.ResourcePath ?? "";
		var key = SpecialEnemies.Names.FirstOrDefault( n => string.Equals( SpecialEnemies.PathFor( n ), path, StringComparison.OrdinalIgnoreCase ) );
		if ( key is null ) return v.IsBoss ? Pretty( v.ResourceName ) : "Zombie";

		return Pretty( key );
	}

	static string Pretty( string key )
		=> string.Join( " ", (key ?? "").Split( '_', StringSplitOptions.RemoveEmptyEntries )
			.Select( word => char.ToUpperInvariant( word[0] ) + word.Substring( 1 ) ) );

	static string Check( string path ) => Texture.Load( path, false )?.IsValid() == true ? "ok" : "MISSING";

	/// <summary>`nz_hbar_info` — the settings, the zombie aimed at, the bar shown and its health as this machine reads it.</summary>
	[ConCmd( "nz_hbar_info" )]
	public static void Info()
	{
		Log.Info( $"[nz-hbar] on {On} · damage {ShowDamage} · name {ShowName} · textured {AltGround} · reach {MaxDistance:0}u"
			+ $" · textures {Check( IconNormalPath )}/{Check( IconBossPath )}/{Check( AltGroundPath )} · this frame {Frame.Count} piece(s)" );

		var me = Game.ActiveScene?.GetAllComponents<ZombieHealthBars>().FirstOrDefault();
		if ( me is null )
		{
			Log.Info( "[nz-hbar]   no ZombieHealthBars in the scene (SurvivalHud mounts it)" );
			return;
		}

		var cam = me.Scene.Camera;
		var aimed = cam.IsValid() ? me.Aimed( cam ) : null;
		Log.Info( $"[nz-hbar]   aimed at {(aimed is null ? "nothing" : NameOf( aimed ) + $" {aimed.HealthNow:0}/{aimed.HealthMax:0}")}"
			+ $" · showing {(me._shown is null ? "nothing" : NameOf( me._shown ))} · {me._bars.Count} bar(s) kept · composed yet {me._drewOnce}"
			+ $" · last hit {(_hitZombie.IsValid() ? NameOf( _hitZombie ) + (_hitHead ? " (head)" : "") : "none")}" );
	}
}