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