UI component that draws revive markers over downed teammates. It builds and positions an icon, a progress bar and a countdown for each downed NZPlayer (except the local player), updates them each frame, manages a preview marker for testing, and exposes console commands to test and report marker state.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Collections.Generic;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
THE REVIVE MARKER — an icon over every downed teammate, and the bar that fills
while you pick them up.
⛔ WITHOUT THIS A DOWNED TEAMMATE IS INVISIBLE. A crawling player is at ankle
height behind whatever they fell behind; on a map with any cover at all you
cannot find them, and the `UsePrompt` line only appears once you are already
within 90 units of a body you had no way to locate. The icon is what makes the
revive a thing you can go and DO rather than stumble into.
⚠️ DRAWN THROUGH WALLS, DELIBERATELY. Every other world marker in this project
is line-of-sight; this one must not be, for the reason above — an icon you can
only see once you can already see the body tells you nothing you did not know.
⛔ BuildHash IS CONSTANT, exactly as DamageNumbersHud and PointsPopupHud are,
and for the same reason: a PanelComponent destroys and rebuilds its whole tree
whenever the hash changes, and these are repositioned every single frame. A
hash over the marker set would rebuild every marker sixty times a second.
Children are added and removed from code instead.
⚠️ NO ASSET. The badge is built from panels rather than an image because the
only revive-ish art in the project is `ui/perks/revive.png`, the Quick Revive
BOTTLE — which over a body reads as "a perk is here", not "your friend is
down". A cross is unambiguous at any size and cannot be mistaken for a pickup.
*@
<root class="revive-markers"></root>
@code
{
/// <summary>⛔ CONSTANT ON PURPOSE — see the header.</summary>
protected override int BuildHash() => 0;
/// <summary>
/// How far up from the body's origin the badge floats, in world units.
///
/// ⚠️ MEASURED FROM THE FEET, NOT THE HEAD. `WorldPosition` on a player is the
/// floor under them, and a downed player's head is on the floor too — so this
/// is the whole offset, not a nudge on top of a standing height.
/// </summary>
public static float Lift { get; set; } = 40f;
/// <summary>Seconds of bleedout left at which the badge turns red.</summary>
public static float UrgentAt { get; set; } = 10f;
readonly Dictionary<NZPlayer, Marker> _live = new();
readonly List<NZPlayer> _dead = new();
/// <summary>
/// A marker with no player behind it, for looking at the art.
///
/// ⛔ THE REAL ONE IS NEVER DRAWN OVER MY OWN BODY, so solo there is nothing on screen to
/// look at — and the badge, the bar, the urgent pulse and the countdown are four pieces of
/// appearance that a second machine would only ever show me in a firefight. This is the same
/// panels through the same `Build` and the same `Place` maths, so what it shows is what a real
/// marker looks like; only the anchor is fake.
///
/// ⚠️ NOT A DUMMY PLAYER. `ReviveAugments`' own header explains why a second `NZPlayer` in
/// the scene is not worth building — 122 sites resolve "the player" as `FirstOrDefault`. A
/// dummy MARKER touches nothing but this component.
/// </summary>
Marker _preview;
static Vector3 _previewAt;
static TimeUntil _previewUntil;
static float _previewSecs;
static float _previewFill;
/// <summary>One downed player's marker: the badge, the bar, the count.</summary>
class Marker
{
public Panel Root;
public Panel Badge;
public Panel Fill;
public Label Secs;
}
protected override void OnUpdate()
{
if ( Panel is null ) return;
// ⚠️ THE THEME'S CLASS FROM HERE, NOT FROM THE RAZOR: this tree is built once (`HudTheme.Wear`).
HudTheme.Wear( Panel );
Sync();
Place();
Preview();
}
/// <summary>Draw, move or retire the fake marker. Does nothing unless asked.</summary>
void Preview()
{
var want = _previewUntil > 0f;
if ( !want )
{
if ( _preview is null ) return;
_preview.Root.Delete();
_preview = null;
return;
}
_preview ??= Build();
var camera = Scene?.Camera;
if ( !camera.IsValid() ) return;
var screen = camera.PointToScreenPixels( _previewAt + Vector3.Up * Lift, out var behind );
_preview.Root.Style.Opacity = behind ? 0f : 1f;
if ( behind ) return;
var scale = Panel.ScaleFromScreen;
_preview.Root.Style.Left = Length.Pixels( screen.x * scale );
_preview.Root.Style.Top = Length.Pixels( screen.y * scale );
// ⚠️ THE BAR SWEEPS ON ITS OWN, because a still bar cannot show whether the fill
// clips its rounded track — which is the one thing about it that can look wrong.
var t = (_previewFill + Time.Now * 0.35f) % 1f;
_preview.Fill.Style.Width = Length.Fraction( t );
_preview.Fill.Parent.Style.Opacity = 1f;
_preview.Secs.Text = $"{_previewSecs:0}";
_preview.Badge.SetClass( "urgent", _previewSecs <= UrgentAt );
_preview.Badge.SetClass( "helping", _previewSecs > UrgentAt );
}
/// <summary>Add a marker for anyone newly down, retire anyone who is not.</summary>
void Sync()
{
var me = NZPlayer.Local;
foreach ( var go in PlayerSpawner.AllBodies() )
{
var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !p.IsValid() ) continue;
// ⛔ NOT MYSELF. My own down is `DownedHud`, full screen, with the bleedout
// that matters. A badge over my own head would sit in the middle of it.
if ( p == me ) continue;
// ⚠️ `IsOutOfRound` IS THE ONE THAT CROSSES MACHINES. `HasBledOut` is local — on my
// copy of your body it is always false — so a marker keyed to it would hang over a
// teammate who has already despawned, pointing at nothing, for the rest of the wave.
var want = p.IsDown && !p.IsOutOfRound;
if ( want && !_live.ContainsKey( p ) ) _live[p] = Build();
else if ( !want && _live.TryGetValue( p, out var gone ) )
{
gone.Root.Delete();
_live.Remove( p );
}
}
// ⚠️ A BODY CAN LEAVE THE SCENE WHILE DOWN — a disconnect mid-bleedout — and the
// loop above only ever visits bodies that still exist. Without this its marker
// would hang in the air for the rest of the game.
_dead.Clear();
foreach ( var (p, _) in _live )
if ( !p.IsValid() ) _dead.Add( p );
foreach ( var p in _dead )
{
_live[p].Root.Delete();
_live.Remove( p );
}
}
Marker Build()
{
var root = new Panel();
root.AddClass( "marker" );
Panel.AddChild( root );
var badge = new Panel();
badge.AddClass( "badge" );
root.AddChild( badge );
// ⚠️ TWO PANELS, NOT A GLYPH. A "✚" in a Label depends on the UI font actually
// carrying that codepoint, and a missing one draws a blank box rather than
// failing loudly. Two rectangles cannot be missing.
var v = new Panel(); v.AddClass( "cross-v" ); badge.AddChild( v );
var h = new Panel(); h.AddClass( "cross-h" ); badge.AddChild( h );
var track = new Panel();
track.AddClass( "bar" );
root.AddChild( track );
var fill = new Panel();
fill.AddClass( "fill" );
track.AddChild( fill );
var secs = new Label();
secs.AddClass( "secs" );
root.AddChild( secs );
return new Marker { Root = root, Badge = badge, Fill = fill, Secs = secs };
}
/// <summary>Project every marker to the screen and set its numbers.</summary>
void Place()
{
var camera = Scene?.Camera;
if ( !camera.IsValid() ) return;
var me = NZPlayer.Local;
var scale = Panel.ScaleFromScreen;
foreach ( var (p, m) in _live )
{
if ( !p.IsValid() ) continue;
var screen = camera.PointToScreenPixels( p.WorldPosition + Vector3.Up * Lift, out var behind );
// ⚠️ HIDDEN, NOT DELETED. Turning away from a downed teammate must not cost
// the marker — it has to be there the instant you turn back.
if ( behind )
{
m.Root.Style.Opacity = 0f;
continue;
}
m.Root.Style.Opacity = 1f;
m.Root.Style.Left = Length.Pixels( screen.x * scale );
m.Root.Style.Top = Length.Pixels( screen.y * scale );
// ── the bar ───────────────────────────────────────────────────────────────
//
// ⛔ MY progress, not theirs. `ReviveProgress` lives on the RESCUER so that two
// players racing to revive one teammate do not each contribute half — which
// means the number that fills this bar is on MY player, keyed to whether I am
// the one currently working on THIS body.
var mine = me.IsValid() && me.RevivingWho == p;
var total = me.IsValid() ? ReviveAugments.SecondsFor( me ) : 1f;
var frac = mine && total > 0f
? (me.ReviveProgress / total).Clamp( 0f, 1f )
: 0f;
m.Fill.Style.Width = Length.Fraction( frac );
// ⚠️ THE BAR IS ONLY SHOWN WHILE IT MEANS SOMETHING. An empty track over every
// downed player across the map is a row of identical grey lines saying nothing;
// it appears when this particular revive is under way.
m.Fill.Parent.Style.Opacity = mine ? 1f : 0f;
// ── the countdown ─────────────────────────────────────────────────────────
// ⛔ `BleedsOutIn` IS A LOCAL `TimeUntil` AND READS ZERO ON A PROXY. It was never
// started on my copy of your body, so this marker showed **0** from the moment a
// teammate fell to the moment they died — the one number it exists to carry.
// `BleedoutShown` asks the owner's published int for anybody but me.
var left = p.BleedoutShown;
m.Secs.Text = $"{left}";
m.Badge.SetClass( "urgent", left <= UrgentAt );
m.Badge.SetClass( "helping", mine );
}
}
// ── console ──────────────────────────────────────────────────────────────────────
/// <summary>
/// `nz_revive_hud_test [seconds] [bleedout]` — pin a fake marker in front of you and look at it.
///
/// ⚠️ `bleedout` PICKS WHICH STATE IT SHOWS, because the two are different art: at or under
/// `UrgentAt` it is the red pulsing badge, above it the green "somebody is on it" one. The
/// default sits above, so `nz_revive_hud_test 20 5` is how the red one gets looked at.
///
/// ⚠️ 0 SECONDS CLEARS IT, so a preview left running cannot outlive the session it was
/// typed in and be mistaken for a real teammate.
/// </summary>
[ConCmd( "nz_revive_hud_test" )]
public static void Test( float seconds = 20f, float bleedout = 30f )
{
_previewUntil = seconds;
_previewSecs = bleedout;
_previewFill = 0f;
var camera = Game.ActiveScene?.Camera;
// ⚠️ PLACED IN THE WORLD, NOT ON THE SCREEN, so turning away hides it exactly as a real
// marker does — which is half of what there is to check.
_previewAt = camera.IsValid()
? camera.WorldPosition + camera.WorldRotation.Forward * 150f - Vector3.Up * 40f
: Vector3.Zero;
Log.Info( seconds <= 0f
? "[nz-rev] preview cleared"
: $"[nz-rev] preview marker at {_previewAt} for {seconds:0}s"
+ $" · showing the {(bleedout <= UrgentAt ? "URGENT (red, pulsing)" : "being-helped (green)")} state" );
}
/// <summary>
/// `nz_revive_hud` — how many markers are up, and why there are not more.
///
/// ⛔ A MARKER THAT NEVER APPEARS AND A MARKER BEHIND YOU LOOK THE SAME: nothing on
/// screen. This separates "nobody is down as far as this machine knows" — which is the
/// replication failing, and `nz_revive_state` names it — from "somebody is down and the
/// marker was not built" from "it exists and is off-screen".
/// </summary>
[ConCmd( "nz_revive_hud" )]
public static void Report()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Info( "[nz-rev] no scene" ); return; }
var hud = scene.GetAllComponents<ReviveHud>().FirstOrDefault();
if ( hud is null )
{
Log.Warning( "[nz-rev] ⛔ no ReviveHud in the scene — SurvivalHud attaches it in"
+ " OnUpdate, so this means the survival HUD itself is not running" );
return;
}
var down = PlayerSpawner.AllBodies()
.Select( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
.Where( p => p.IsValid() && p.IsDown )
.ToList();
Log.Info( $"[nz-rev] {hud._live.Count} marker(s) up · {down.Count} player(s) down"
+ $" (mine included; a marker is never drawn over my own body)" );
foreach ( var p in down )
Log.Info( $"[nz-rev] '{p.GameObject.Name}' mine={PlayerPresence.Mine( p.GameObject )}"
+ $" bledOut={p.HasBledOut} marker={hud._live.ContainsKey( p )}"
+ $" beingRevived={p.BeingRevived} ({p.BeingRevivedFraction:0.00})" );
}
}