UI/DirectorDebugHud.cs

A UI component that displays a compact debug HUD for the adaptive Director system. It locates a DemoDirectorRuntime in the scene, reads runtime state and recent decisions each frame, and renders basic and advanced telemetry text labels with toggles and simple formatting.

Reflection
using AdaptiveDirectorDemo.Director;
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using SboxDirector;
using System;
using System.Linq;

namespace AdaptiveDirectorDemo.UI;

/// <summary>Compact top-left live telemetry for the adaptive Director.</summary>
[Title( "Director Debug HUD" )]
[Category( "Adaptive Director Demo" )]
public sealed class DirectorDebugHud : PanelComponent
{
	private const string ToggleAction = "DirectorDebug";

	private Label _title;
	private Label _telemetry;
	private Label _phaseValue;
	private Label _advancedTelemetry;
	private Label _controlsHint;
	private DemoDirectorRuntime _runtime;
	private bool _showAdvanced;

	protected override void OnStart()
	{
		Panel.StyleSheet.Load( "/UI/DirectorDebugHud.cs.scss" );
		_title = Panel.Add.Label( "ADAPTIVE DIRECTOR", "director-title" );
		_telemetry = Panel.Add.Label( "STATUS       STARTING", "director-telemetry basic" );
		_phaseValue = Panel.Add.Label( "INITIALIZING", "director-phase-value phase-initializing" );
		_advancedTelemetry = Panel.Add.Label( "", "director-telemetry advanced" );
		_controlsHint = Panel.Add.Label( "Q FOR WEAPON ATTACHMENT   C FOR THIRD PERSON", "director-controls" );
	}

	protected override void OnUpdate()
	{
		if ( Input.Pressed( ToggleAction ) )
		{
			_showAdvanced = !_showAdvanced;
			_advancedTelemetry.SetClass( "visible", _showAdvanced );
		}

		if ( _runtime is null || !_runtime.IsValid )
			_runtime = Scene.GetAllComponents<DemoDirectorRuntime>().FirstOrDefault();

		if ( _runtime is null )
		{
			_telemetry.Text = "STATUS       OFFLINE";
			_advancedTelemetry.Text = "DIRECTOR RUNTIME NOT FOUND";
			return;
		}

		var decisions = _runtime.LastDecisions;
		var state = _runtime.Director?.CaptureState();
		var phase = decisions?.Tempo.ToString() ?? state?.Tempo.Phase.ToString() ?? "INITIALIZING";
		var pressure = decisions?.TeamPressure ?? 0.0f;
		var intensity = decisions?.MusicIntensity ?? 0.0f;
		var requests = decisions?.SpawnRequests.Count ?? 0;
		var events = decisions?.Events.Count ?? 0;
		var pending = state?.Population.Pending.Count ?? 0;
		var reserved = _runtime.Director?.Reservations.Values.Sum() ?? 0;
		var participants = _runtime.LastWorld.Participants.Count;
		var combat = _runtime.LastWorld.CombatActive ? "YES" : "NO";
		UpdatePhaseValue( phase );

		_telemetry.Text =
			$"STATUS       {_runtime.LastRunStatus}\n" +
			$"PHASE\n" +
			$"PRESSURE     {pressure:0.000} {BuildMeter( pressure )}\n" +
			$"MUSIC INT.   {intensity:0.000} {BuildMeter( intensity )}\n" +
			$"PARTICIPANTS {participants}\n" +
			$"COMBAT       {combat}" +
			(_showAdvanced ? "" : "\n\n[+] Press F to show advanced settings");

		_advancedTelemetry.Text =
			BuildAdvancedTelemetry( state, decisions, requests, events, pending, reserved ) +
			"\n\n[-] Press F to hide advanced settings";
	}

	private void UpdatePhaseValue( string phase )
	{
		_phaseValue.SetClass( "phase-initializing", phase == "INITIALIZING" );
		_phaseValue.SetClass( "phase-relax", phase == TempoPhase.Relax.ToString() );
		_phaseValue.SetClass( "phase-buildup", phase == TempoPhase.BuildUp.ToString() );
		_phaseValue.SetClass( "phase-sustain", phase == TempoPhase.SustainPeak.ToString() );
		_phaseValue.SetClass( "phase-fade", phase == TempoPhase.PeakFade.ToString() );
		_phaseValue.Text = phase switch
		{
			nameof( TempoPhase.Relax ) => "RELAX / COOLDOWN",
			nameof( TempoPhase.BuildUp ) => "BUILD UP",
			nameof( TempoPhase.SustainPeak ) => "SUSTAIN PEAK",
			nameof( TempoPhase.PeakFade ) => "PEAK FADE",
			_ => "INITIALIZING"
		};
	}

	private string BuildAdvancedTelemetry(
		DirectorState? state,
		DirectorDecisionBatch decisions,
		int requests,
		int events,
		int pending,
		int reserved )
	{
		var world = _runtime.LastWorld;
		var configuration = _runtime.ActiveConfiguration;
		var phaseSeconds = state is null ? 0.0 : Math.Max( 0.0, world.Time - state.Value.Tempo.PhaseStarted );
		var target = state?.Pressure
			.OrderByDescending( pair => pair.Value.Instantaneous )
			.FirstOrDefault();
		var targetId = string.IsNullOrWhiteSpace( target?.Key ) ? "NONE" : target.Value.Key;
		var targetPressure = target?.Value.Instantaneous ?? 0.0f;
		var currentRequest = decisions?.SpawnRequests.FirstOrDefault() ?? _runtime.LastSpawnRequest;
		var candidate = currentRequest is null
			? null
			: world.SpawnCandidates.FirstOrDefault( item => item.Id == currentRequest.CandidateId );
		var cooldown = GetNextCooldown( state, world.Time );
		var nextRetry = state is null || state.Value.RetryQueue.Count == 0
			? "NONE"
			: $"{state.Value.RetryQueue[0].Kind} IN {Math.Max( 0.0, state.Value.RetryQueue[0].NotBefore - world.Time ):0.0}s";
		var lastResult = _runtime.LastSpawnResult is null
			? "NONE"
			: $"{_runtime.LastSpawnResult.Result}: {_runtime.LastSpawnResult.Detail ?? "NO DETAIL"}";
		var nextSpawn = currentRequest is null
			? "WAITING"
			: $"{currentRequest.Kind} x{currentRequest.RequestedCount}";
		var area = currentRequest is null
			? "NONE"
			: $"{currentRequest.CandidateId} SCORE {ExtractScore( currentRequest.Reason )}";
		var areaChecks = candidate is null
			? "NO CANDIDATE SELECTED"
			: $"DIST {candidate.NearestParticipantDistance:0}  VISIBLE {(candidate.Visible ? "YES" : "NO")}";

		var population = string.Join( "  ", Enum.GetValues<EncounterKind>().Select( kind =>
			$"{Abbreviate( kind )}:{world.Population[kind]}" ) );
		var totalLimit = configuration.Population.Limits.Values.Sum();
		var totalActive = configuration.Population.Limits.Keys.Sum( kind => world.Population[kind] );
		var availableSlots = Math.Max( 0, totalLimit - totalActive - reserved );
		var playerPressure = state is null || state.Value.Pressure.Count == 0
			? "NONE"
			: string.Join( "  ", state.Value.Pressure.Select( pair =>
				$"{ShortId( pair.Key )}:{pair.Value.Instantaneous:0.00}/{pair.Value.Averaged:0.00}" ) );
		var session = _runtime.RoundBased ? "ROUND OVERRIDE" : "CONTINUOUS";

		return
			$"\nSTATE TIMER  {phaseSeconds:0.0}s\n" +
			$"PEAK MUSIC   {_runtime.RecentPeakMusicIntensity:0.000} (10s)\n" +
			$"PRESSURE SRC {_runtime.LastPressureSignal}\n" +
			$"THREATS      NEAR {_runtime.NearbyThreatCount}  VISIBLE {_runtime.VisibleThreatCount}  SURROUNDED {(_runtime.IsPlayerSurrounded ? "YES" : "NO")}\n" +
			$"PLAYER       HP {_runtime.PlayerHealthFraction:0.00}  AMMO {_runtime.ActiveWeaponAmmoFraction:0.00}  COMBAT {_runtime.SustainedCombatSeconds:0.0}s\n" +
			$"TARGET       {ShortId( targetId )} ({targetPressure:0.000})\n" +
			$"PLAYER I/A   {playerPressure}\n" +
			$"PROGRESS     {world.TeamProgress:0.000} {BuildMeter( world.TeamProgress )}\n" +
			$"POPULATION   {population}\n" +
			$"SPAWN BUDGET {availableSlots}/{totalLimit}  REGEN HOST\n" +
			$"NEXT SPAWN   {nextSpawn}\n" +
			$"COOLDOWN     {cooldown}\n" +
			$"NEXT RETRY   {nextRetry}\n" +
			$"AREA         {area}\n" +
			$"AREA CHECKS  {areaChecks}\n" +
			$"LAST RESULT  {lastResult}\n" +
			$"REQUESTS     {requests}  EVENTS {events}\n" +
			$"PENDING      {pending}  RESERVED {reserved}\n" +
			$"MODE         {world.ModeId} / {session}\n" +
			$"DIRECTOR     {(_runtime.DirectorEnabled ? "ENABLED" : "OVERRIDDEN")}\n" +
			$"SEED/TICK    {_runtime.Seed} / {_runtime.TickCount} @ {world.Time:0.00}\n" +
			$"LAST         {_runtime.LastDiagnostic}";
	}

	private static string GetNextCooldown( DirectorState? state, double time )
	{
		if ( state is null || state.Value.Schedule.Count == 0 )
			return "READY";

		var remaining = state.Value.Schedule.Values
			.Select( value => double.TryParse( value, out var parsed ) ? parsed - time : double.NaN )
			.Where( value => double.IsFinite( value ) )
			.DefaultIfEmpty( 0.0 )
			.Min();

		return remaining <= 0.0 ? "READY" : $"{remaining:0.0}s";
	}

	private static string ExtractScore( string reason )
	{
		const string marker = "score=";
		var start = reason.IndexOf( marker, StringComparison.Ordinal );
		if ( start < 0 )
			return "N/A";

		start += marker.Length;
		var end = reason.IndexOf( ';', start );
		return end < 0 ? reason[start..] : reason[start..end];
	}

	private static string Abbreviate( EncounterKind kind ) => kind switch
	{
		EncounterKind.Ambient => "A",
		EncounterKind.CommonWave => "C",
		EncounterKind.Special => "S",
		EncounterKind.Boss => "B",
		EncounterKind.DormantHazard => "D",
		EncounterKind.Objective => "O",
		_ => "X"
	};

	private static string ShortId( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) || id.Length <= 8 )
			return id;

		return id[..8];
	}

	private static string BuildMeter( float value, int width = 20 )
	{
		var filled = (int)MathF.Round( Math.Clamp( value, 0.0f, 1.0f ) * width );
		return $"|{new string( '-', filled )}{new string( '.', width - filled )}|";
	}
}