Runtime component that adapts the generalized Director system to the demo s&box world. It captures world snapshots, evaluates pressure signals, builds spawn candidates, executes spawn requests by creating DemoNpc objects, and reports diagnostics and simple combat-related state.
using Sandbox;
using Sandbox.Navigation;
using SboxDirector;
using System;
using System.Collections.Generic;
using System.Linq;
namespace AdaptiveDirectorDemo.Director;
/// <summary>
/// Live adapter between the generalized Director and the demo's s&box world.
/// Every NPC and gameplay pressure signal is resolved through DirectorCore.
/// </summary>
[Title( "Demo Director Runtime" )]
[Category( "Adaptive Director Demo" )]
public sealed class DemoDirectorRuntime : AdaptiveDirectorComponent
{
public WorldSnapshot LastWorld { get; private set; } = new();
public string LastDiagnostic { get; private set; } = "waiting for first tick";
public DirectorConfiguration ActiveConfiguration { get; private set; } = new();
public SpawnRequest LastSpawnRequest { get; private set; }
public SpawnRequestResult LastSpawnResult { get; private set; }
public int TickCount { get; private set; }
public float RecentPeakMusicIntensity { get; private set; }
public string LastPressureSignal { get; private set; } = "none";
public int NearbyThreatCount { get; private set; }
public int VisibleThreatCount { get; private set; }
public bool IsPlayerSurrounded { get; private set; }
public float PlayerHealthFraction { get; private set; } = 1.0f;
public float ActiveWeaponAmmoFraction { get; private set; } = 1.0f;
public float SustainedCombatSeconds { get; private set; }
private readonly List<(double Time, float Intensity)> _musicIntensityHistory = new();
private readonly Dictionary<string, Vector3> _candidatePositions = new();
private readonly Dictionary<string, NpcRetentionState> _npcRetention = new();
private Vector3 _progressOrigin;
private bool _hasProgressOrigin;
private float _furthestTravel;
private double _nextThreatReport;
private double _nextContextPressureReport;
private double _nextDamagePressureReport;
private double _nextKillPressureReport;
private double _combatStartedAt = -1.0;
private int _lastPlayerHealth;
private bool _hasPlayerHealth;
protected override DirectorConfiguration CreateConfiguration()
{
ActiveConfiguration = new DirectorConfiguration
{
Seed = Seed,
RequestTimeoutSeconds = 5.0,
MaximumRequestsPerTick = 1,
Tempo = new TempoConfiguration
{
BuildUpMinimumSeconds = 7.0,
SustainPeakSeconds = 9.0,
RelaxMinimumSeconds = 8.0,
RelaxMaximumSeconds = 14.0,
PeakThreshold = 0.45f,
RelaxThreshold = 0.22f,
RelaxMaximumProgressTravel = 0.08f
},
Population = new PopulationConfiguration
{
Limits = new Dictionary<EncounterKind, int>
{
[EncounterKind.Ambient] = 5,
[EncounterKind.CommonWave] = 12,
[EncounterKind.Special] = 3,
[EncounterKind.Boss] = 1,
[EncounterKind.DormantHazard] = 1,
[EncounterKind.Objective] = 4,
[EncounterKind.Custom] = 4
}
},
Placement = CreatePlacementProfiles()
};
return ActiveConfiguration;
}
protected override IEncounterSchedule CreateSchedule()
{
return new RuleBasedEncounterSchedule( new[]
{
new EncounterRule
{
Id = "boss",
Kind = EncounterKind.Boss,
Count = 1,
Priority = 40,
CooldownSeconds = 42.0,
MinimumPressure = 0.12f,
MaximumPressure = 0.86f,
Probability = 0.55f,
Phases = new HashSet<TempoPhase> { TempoPhase.SustainPeak }
},
new EncounterRule
{
Id = "special",
Kind = EncounterKind.Special,
Count = 1,
Priority = 30,
CooldownSeconds = 12.0,
Probability = 0.45f,
Phases = new HashSet<TempoPhase> { TempoPhase.BuildUp, TempoPhase.SustainPeak }
},
new EncounterRule
{
Id = "wave",
Kind = EncounterKind.CommonWave,
Count = 5,
Priority = 20,
CooldownSeconds = 8.0,
Phases = new HashSet<TempoPhase> { TempoPhase.BuildUp, TempoPhase.SustainPeak }
},
new EncounterRule
{
Id = "ambient",
Kind = EncounterKind.Ambient,
Count = 1,
Priority = 10,
CooldownSeconds = 3.5,
Phases = new HashSet<TempoPhase> { TempoPhase.Relax, TempoPhase.PeakFade }
}
} );
}
protected override IEncounterComposer CreateComposer()
{
return new WeightedEncounterComposer( new Dictionary<EncounterKind, IReadOnlyList<WeightedArchetype>>
{
[EncounterKind.Ambient] = new[]
{
new WeightedArchetype( "drifter", 3.0f ),
new WeightedArchetype( "shambler", 1.0f )
},
[EncounterKind.CommonWave] = new[]
{
new WeightedArchetype( "runner", 4.0f ),
new WeightedArchetype( "bruiser", 1.25f ),
new WeightedArchetype( "swarm", 2.25f )
},
[EncounterKind.Special] = new[]
{
new WeightedArchetype( "stalker", 2.0f ),
new WeightedArchetype( "charger", 1.0f ),
new WeightedArchetype( "screecher", 1.5f )
},
[EncounterKind.Boss] = new[]
{
new WeightedArchetype( "juggernaut", 3.0f ),
new WeightedArchetype( "behemoth", 1.0f )
}
} );
}
protected override void OnDirectorInitialized( DirectorCore director )
{
// Use a named handler so s&box hot load can remap the delegate safely.
director.Diagnostic += HandleDirectorDiagnostic;
}
private void HandleDirectorDiagnostic( DirectorDiagnostic diagnostic )
{
LastDiagnostic = $"{diagnostic.Category}: {diagnostic.Message}";
}
protected override WorldSnapshot CaptureDirectorWorld()
{
var player = FindPlayer();
var participants = new List<ParticipantSnapshot>();
var livingNpcs = Scene.GetAllComponents<DemoNpc>().Where( npc => npc.IsAlive ).ToArray();
var combatActive = false;
var progress = 0.0f;
if ( player is not null )
{
CullStaleNpcs( player, livingNpcs );
livingNpcs = livingNpcs.Where( npc => npc.IsAlive && npc.IsValid ).ToArray();
if ( !_hasProgressOrigin )
{
_progressOrigin = player.WorldPosition;
_hasProgressOrigin = true;
}
_furthestTravel = Math.Max( _furthestTravel, (player.WorldPosition - _progressOrigin).Length );
progress = Math.Clamp( _furthestTravel / 5000.0f, 0.0f, 1.0f );
combatActive = livingNpcs.Any( npc => (npc.WorldPosition - player.WorldPosition).Length < 650.0f );
EvaluatePressureSignals( player, livingNpcs, combatActive );
participants.Add( new ParticipantSnapshot
{
Id = player.Id.ToString(),
IsAlive = player.Active,
IsEligible = player.Active,
IsInCombat = combatActive,
Position = ToDirectorVector( player.WorldPosition ),
Progress = progress
} );
}
var populationCounts = livingNpcs
.GroupBy( npc => npc.Kind )
.ToDictionary( group => group.Key, group => group.Count() );
LastWorld = new WorldSnapshot
{
Time = Time.Now,
Participants = participants,
Population = new PopulationSnapshot( populationCounts ),
SpawnCandidates = BuildSpawnCandidates( player ),
CombatActive = combatActive,
TeamProgress = progress,
ModeId = "continuous-demo"
};
return LastWorld;
}
protected override SpawnRequestResult ExecuteDirectorSpawn( SpawnRequest request )
{
LastSpawnRequest = request;
if ( !_candidatePositions.TryGetValue( request.CandidateId, out var origin ) )
{
LastSpawnResult = new SpawnRequestResult(
request.RequestId, RequestResultKind.Rejected, 0, "candidate expired before host execution" );
return LastSpawnResult;
}
var spawned = 0;
var composedArchetypes = new List<string>();
foreach ( var member in request.Composition )
{
for ( var count = 0; count < member.Count; count++ )
composedArchetypes.Add( member.Archetype );
}
for ( var index = 0; index < request.RequestedCount; index++ )
{
var angle = index * (MathF.PI * 2.0f / Math.Max( 1, request.RequestedCount ));
var ring = 42.0f + 24.0f * (index / 8);
var offset = new Vector3( MathF.Cos( angle ) * ring, MathF.Sin( angle ) * ring, 0.0f );
var position = SnapToGround( origin + offset );
var npcObject = Scene.CreateObject( false );
npcObject.WorldPosition = position;
npcObject.Tags.Add( "director_npc" );
var npc = npcObject.Components.GetOrCreate<DemoNpc>();
npc.Kind = request.Kind;
npc.Archetype = composedArchetypes.Count > 0
? composedArchetypes[Math.Min( index, composedArchetypes.Count - 1 )]
: request.Archetype;
npcObject.Name = $"Director {request.Kind} - {npc.Archetype}";
npcObject.Enabled = true;
spawned++;
}
LastSpawnResult = new SpawnRequestResult(
request.RequestId,
spawned == request.RequestedCount ? RequestResultKind.Succeeded : RequestResultKind.PartiallySucceeded,
spawned,
$"spawned {spawned} grounded Citizen NPC(s) through the Director host" );
return LastSpawnResult;
}
protected override void OnDirectorDecisions( DirectorDecisionBatch decisions, IReadOnlyList<SpawnRequestResult> results )
{
TickCount++;
if ( decisions.SpawnRequests.Count > 0 )
LastSpawnRequest = decisions.SpawnRequests[0];
if ( results.Count > 0 )
LastSpawnResult = results[results.Count - 1];
_musicIntensityHistory.Add( (decisions.Time, decisions.MusicIntensity) );
_musicIntensityHistory.RemoveAll( sample => decisions.Time - sample.Time > 10.0 );
RecentPeakMusicIntensity = _musicIntensityHistory.Count == 0
? 0.0f
: _musicIntensityHistory.Max( sample => sample.Intensity );
}
public void ReportNpcThreat( DemoNpc npc )
{
if ( Time.Now < _nextThreatReport )
return;
var player = FindPlayer();
if ( player is null )
return;
var playerComponent = FindNamedComponent( player, "DemoPlayer" );
if ( playerComponent is null || !playerComponent.IsValid ||
!TryGetIntProperty( playerComponent, "Health", out var health ) || health <= 0 )
return;
_nextThreatReport = Time.Now + 1.0;
var damage = npc.Kind switch
{
EncounterKind.Special => 7,
EncounterKind.Boss => 12,
_ => 4
};
// The demo deliberately omits incapacitation. Ordinary contact attacks can
// demonstrate damage and low-health pressure, but never reduce the player
// below one hit point. Special NPCs do not have special attack mechanics.
TrySetIntProperty( playerComponent, "Health", Math.Max( 1, health - damage ) );
}
public void ReportNpcKilled( DemoNpc npc )
{
if ( Time.Now < _nextKillPressureReport )
return;
var player = FindPlayer();
if ( player is null )
return;
_nextKillPressureReport = Time.Now + 0.6;
var severity = npc.Kind switch
{
EncounterKind.Boss => PressureSeverity.Major,
EncounterKind.Special => PressureSeverity.Moderate,
_ => PressureSeverity.Minor
};
ReportPressureSignal( player.Id.ToString(), severity, $"{npc.Kind} kill" );
}
/// <summary>Compact live state exposed to the autonomous editor gate.</summary>
public string GetPressureDiagnostics()
{
return $"signal={LastPressureSignal}; near={NearbyThreatCount}; visible={VisibleThreatCount}; " +
$"surrounded={IsPlayerSurrounded}; health={PlayerHealthFraction:0.00}; " +
$"ammo={ActiveWeaponAmmoFraction:0.00}; combat={SustainedCombatSeconds:0.0}s";
}
private void EvaluatePressureSignals( GameObject player, IReadOnlyList<DemoNpc> livingNpcs, bool combatActive )
{
var playerId = player.Id.ToString();
var playerComponent = FindNamedComponent( player, "DemoPlayer" );
if ( playerComponent is not null && playerComponent.IsValid &&
TryGetIntProperty( playerComponent, "Health", out var health ) &&
TryGetIntProperty( playerComponent, "MaxHealth", out var rawMaximumHealth ) )
{
var maximumHealth = Math.Max( 1, rawMaximumHealth );
PlayerHealthFraction = Math.Clamp( health / (float)maximumHealth, 0.0f, 1.0f );
if ( _hasPlayerHealth && health < _lastPlayerHealth && Time.Now >= _nextDamagePressureReport )
{
var damageFraction = (_lastPlayerHealth - health) / (float)maximumHealth;
var severity = damageFraction switch
{
>= 0.25f => PressureSeverity.Critical,
>= 0.10f => PressureSeverity.Major,
_ => PressureSeverity.Moderate
};
_nextDamagePressureReport = Time.Now + 0.6;
ReportPressureSignal( playerId, severity, "player damage" );
}
_lastPlayerHealth = health;
_hasPlayerHealth = true;
}
else
{
PlayerHealthFraction = 1.0f;
_hasPlayerHealth = false;
}
NearbyThreatCount = livingNpcs.Count( npc => (npc.WorldPosition - player.WorldPosition).Length <= 350.0f );
VisibleThreatCount = livingNpcs.Count( npc =>
(npc.WorldPosition - player.WorldPosition).Length <= 1000.0f && IsVisibleToPlayer( npc.WorldPosition ) );
IsPlayerSurrounded = IsSurroundedByThreats( player, livingNpcs );
if ( combatActive )
{
if ( _combatStartedAt < 0.0 )
_combatStartedAt = Time.Now;
SustainedCombatSeconds = (float)Math.Max( 0.0, Time.Now - _combatStartedAt );
}
else
{
_combatStartedAt = -1.0;
SustainedCombatSeconds = 0.0f;
}
var weapon = Scene.GetAllObjects( false )
.SelectMany( item => item.Components.GetAll() )
.FirstOrDefault( component => component.Active && component.Enabled && component.GetType().Name == "Weapon" );
var isShooting = weapon is not null && Input.Down( "attack1" );
var primary = weapon is null ? null : GetPropertyValue( weapon, "Primary" );
if ( primary is not null && TryGetIntProperty( primary, "ClipSize", out var clipSize ) && clipSize > 0 &&
TryGetIntProperty( primary, "Ammo", out var ammo ) )
ActiveWeaponAmmoFraction = Math.Clamp( ammo / (float)clipSize, 0.0f, 1.0f );
else
ActiveWeaponAmmoFraction = 1.0f;
if ( Time.Now < _nextContextPressureReport )
return;
var selectedSeverity = (PressureSeverity)0;
var selectedReason = string.Empty;
void Consider( PressureSeverity severity, string reason )
{
if ( severity <= selectedSeverity )
return;
selectedSeverity = severity;
selectedReason = reason;
}
if ( IsPlayerSurrounded )
Consider( PressureSeverity.Major, "surrounded" );
if ( NearbyThreatCount >= 7 )
Consider( PressureSeverity.Major, "dense nearby horde" );
else if ( NearbyThreatCount >= 3 )
Consider( PressureSeverity.Moderate, "nearby enemy density" );
if ( VisibleThreatCount >= 7 )
Consider( PressureSeverity.Moderate, "many visible enemies" );
else if ( VisibleThreatCount >= 3 )
Consider( PressureSeverity.Minor, "visible enemy density" );
if ( combatActive && PlayerHealthFraction <= 0.25f )
Consider( PressureSeverity.Moderate, "critical health" );
else if ( combatActive && PlayerHealthFraction <= 0.50f )
Consider( PressureSeverity.Minor, "low health" );
if ( combatActive && ActiveWeaponAmmoFraction <= 0.15f )
Consider( PressureSeverity.Moderate, "critical ammunition" );
else if ( combatActive && ActiveWeaponAmmoFraction <= 0.30f )
Consider( PressureSeverity.Minor, "low ammunition" );
if ( isShooting )
Consider( PressureSeverity.Minor, "weapon fire" );
if ( SustainedCombatSeconds >= 25.0f )
Consider( PressureSeverity.Moderate, "prolonged combat" );
else if ( SustainedCombatSeconds >= 10.0f )
Consider( PressureSeverity.Minor, "sustained combat" );
if ( selectedSeverity == (PressureSeverity)0 )
return;
_nextContextPressureReport = Time.Now + 2.0;
ReportPressureSignal( playerId, selectedSeverity, selectedReason );
}
private bool IsSurroundedByThreats( GameObject player, IReadOnlyList<DemoNpc> livingNpcs )
{
var sectors = new HashSet<int>();
var nearby = 0;
foreach ( var npc in livingNpcs )
{
var delta = npc.WorldPosition - player.WorldPosition;
if ( delta.Length > 300.0f )
continue;
nearby++;
var angle = MathF.Atan2( delta.y, delta.x ) + MathF.PI;
var sector = Math.Clamp( (int)(angle / (MathF.PI * 0.5f)), 0, 3 );
sectors.Add( sector );
}
return nearby >= 4 && sectors.Count >= 3;
}
private void ReportPressureSignal( string playerId, PressureSeverity severity, string reason )
{
LastPressureSignal = $"{severity}: {reason}";
ReportPressure( new PressureEvent( playerId, severity, reason ), Time.Now );
}
private static Component FindNamedComponent( GameObject gameObject, string typeName )
{
return gameObject.Components.GetAll()
.FirstOrDefault( component => component.GetType().Name == typeName );
}
private static object GetPropertyValue( object target, string propertyName )
{
if ( target is null )
return null;
var description = TypeLibrary.GetType( target.GetType() );
var property = description?.Properties.FirstOrDefault( item => item.Name == propertyName && !item.IsStatic );
return property?.GetValue( target );
}
private static bool TryGetIntProperty( object target, string propertyName, out int value )
{
value = 0;
var raw = GetPropertyValue( target, propertyName );
if ( raw is not int number )
return false;
value = number;
return true;
}
private static bool TrySetIntProperty( object target, string propertyName, int value )
{
if ( target is null )
return false;
var description = TypeLibrary.GetType( target.GetType() );
var property = description?.Properties.FirstOrDefault( item =>
item.Name == propertyName && !item.IsStatic && item.CanWrite );
if ( property is null )
return false;
property.SetValue( target, value );
return true;
}
private IReadOnlyList<SpawnCandidate> BuildSpawnCandidates( GameObject player )
{
_candidatePositions.Clear();
if ( player is null )
return Array.Empty<SpawnCandidate>();
var positions = new List<(string Id, Vector3 Position, float PathDistance)>();
foreach ( var marker in Scene.GetAllComponents<SpawnPoint>() )
{
if ( !TryProjectReachableSpawn( marker.WorldPosition, player.WorldPosition, out var position, out var pathDistance ) )
continue;
var distance = (position - player.WorldPosition).Length;
if ( distance < 260.0f || distance > 2200.0f )
continue;
positions.Add( ($"map-{marker.GameObject.Id}", position, pathDistance) );
if ( positions.Count >= 36 )
break;
}
if ( positions.Count < 8 )
{
for ( var index = 0; index < 12; index++ )
{
var angle = index * MathF.PI * 2.0f / 12.0f;
var distance = 650.0f + (index % 3) * 220.0f;
var proposed = player.WorldPosition + new Vector3(
MathF.Cos( angle ) * distance,
MathF.Sin( angle ) * distance,
0.0f );
if ( TryProjectReachableSpawn( proposed, player.WorldPosition, out var position, out var pathDistance ) )
positions.Add( ($"ring-{index}", position, pathDistance) );
}
}
var output = new List<SpawnCandidate>();
foreach ( var item in positions )
{
var distance = (item.Position - player.WorldPosition).Length;
_candidatePositions[item.Id] = item.Position;
output.Add( new SpawnCandidate
{
Id = item.Id,
Position = ToDirectorVector( item.Position ),
Reachable = true,
Spawnable = !IsOccupied( item.Position ),
Visible = IsVisibleToPlayer( item.Position ),
NearestParticipantDistance = distance,
Progress = LastWorld.TeamProgress,
RelativeProgress = 0.0f,
AreaCapacity = 8.0f,
ThreatSeparation = NearestNpcDistance( item.Position ),
PathDistance = item.PathDistance,
IngressDistance = item.PathDistance,
ClearRadius = 96.0f,
AreaWidth = 192.0f,
AreaHeight = 96.0f,
AdvanceRelation = AdvanceRelation.Lateral
} );
}
return output;
}
private void CullStaleNpcs( GameObject player, IReadOnlyList<DemoNpc> livingNpcs )
{
var liveIds = new HashSet<string>();
foreach ( var npc in livingNpcs )
{
if ( npc is null || !npc.IsValid || !npc.IsAlive )
continue;
var id = npc.GameObject.Id.ToString();
liveIds.Add( id );
if ( !_npcRetention.TryGetValue( id, out var retention ) )
{
retention = new NpcRetentionState( npc.WorldPosition, Time.Now );
_npcRetention[id] = retention;
}
var distance = (npc.WorldPosition - player.WorldPosition).Length;
var moved = (npc.WorldPosition - retention.LastPosition).Length >= 48.0f;
var relevant = distance <= 900.0f || IsVisibleToPlayer( npc.WorldPosition ) || moved;
if ( relevant )
{
retention.LastRelevantTime = Time.Now;
if ( moved )
retention.LastPosition = npc.WorldPosition;
continue;
}
// The host, not DirectorCore, owns entity visibility and destruction.
// Twenty seconds keeps cleanup conservative while preventing unreachable,
// off-screen encounters from permanently consuming population capacity.
if ( Time.Now - retention.LastRelevantTime >= 20.0 )
{
npc.DespawnForDirector();
LastDiagnostic = $"cleanup: released stale off-screen {npc.Kind} encounter";
}
}
foreach ( var staleId in _npcRetention.Keys.Where( id => !liveIds.Contains( id ) ).ToArray() )
_npcRetention.Remove( staleId );
}
private bool TryProjectReachableSpawn(
Vector3 proposed,
Vector3 playerPosition,
out Vector3 position,
out float pathDistance )
{
position = default;
pathDistance = 0.0f;
var navMesh = Scene.NavMesh;
if ( navMesh is null || !navMesh.IsEnabled )
return false;
// A small snap radius prevents a ground-level proposal from snapping back
// onto a roof or another vertically separated navigation island.
var candidatePoint = navMesh.GetClosestPoint( proposed, 192.0f );
var playerPoint = navMesh.GetClosestPoint( playerPosition, 192.0f );
if ( candidatePoint is null || playerPoint is null )
return false;
const float maximumVerticalSeparation = 128.0f;
if ( MathF.Abs( candidatePoint.Value.z - playerPoint.Value.z ) > maximumVerticalSeparation )
return false;
var path = navMesh.CalculatePath( new CalculatePathRequest
{
Start = playerPoint.Value,
Target = candidatePoint.Value
} );
if ( path.Status != NavMeshPathStatus.Complete || path.Points is null || path.Points.Count == 0 )
return false;
for ( var index = 1; index < path.Points.Count; index++ )
pathDistance += (path.Points[index].Position - path.Points[index - 1].Position).Length;
position = SnapToGround( candidatePoint.Value );
return MathF.Abs( position.z - playerPoint.Value.z ) <= maximumVerticalSeparation;
}
private Vector3 SnapToGround( Vector3 position )
{
var navPoint = Scene.NavMesh?.GetClosestPoint( position, 512.0f );
var basePoint = navPoint ?? position;
var start = basePoint + Vector3.Up * 192.0f;
var end = basePoint + Vector3.Down * 512.0f;
var trace = Scene.Trace.Ray( start, end )
.WithoutTags( "player", "director_npc" )
.Run();
return trace.Hit ? trace.EndPosition : basePoint;
}
private bool IsVisibleToPlayer( Vector3 position )
{
var camera = Scene.Camera;
if ( camera is null )
return false;
var target = position + Vector3.Up * 40.0f;
var delta = target - camera.WorldPosition;
if ( Vector3.Dot( camera.WorldRotation.Forward, delta.Normal ) < 0.2f )
return false;
var trace = Scene.Trace.Ray( camera.WorldPosition, target )
.WithoutTags( "player", "director_npc" )
.Run();
return !trace.Hit || trace.Distance >= delta.Length - 48.0f;
}
private bool IsOccupied( Vector3 position )
{
return Scene.GetAllComponents<DemoNpc>()
.Any( npc => npc.IsAlive && (npc.WorldPosition - position).Length < 110.0f );
}
private float NearestNpcDistance( Vector3 position )
{
return Scene.GetAllComponents<DemoNpc>()
.Where( npc => npc.IsAlive )
.Select( npc => (npc.WorldPosition - position).Length )
.DefaultIfEmpty( 2500.0f )
.Min();
}
private GameObject FindPlayer()
{
return Scene.GetAllObjects( false ).FirstOrDefault( item => item.Name == "Player" && item.Active );
}
private static DirectorVector ToDirectorVector( Vector3 value ) => new( value.x, value.y, value.z );
private sealed class NpcRetentionState
{
public Vector3 LastPosition { get; set; }
public double LastRelevantTime { get; set; }
public NpcRetentionState( Vector3 lastPosition, double lastRelevantTime )
{
LastPosition = lastPosition;
LastRelevantTime = lastRelevantTime;
}
}
private static IReadOnlyDictionary<EncounterKind, PlacementProfile> CreatePlacementProfiles()
{
return new Dictionary<EncounterKind, PlacementProfile>
{
[EncounterKind.Ambient] = DemoPlacement( 280.0f, 750.0f, 1800.0f ),
[EncounterKind.CommonWave] = DemoPlacement( 420.0f, 1000.0f, 2000.0f ),
[EncounterKind.Special] = DemoPlacement( 520.0f, 1250.0f, 2100.0f ),
[EncounterKind.Boss] = DemoPlacement( 700.0f, 1500.0f, 2200.0f ),
[EncounterKind.DormantHazard] = DemoPlacement( 600.0f, 1300.0f, 2200.0f ),
[EncounterKind.Objective] = DemoPlacement( 200.0f, 700.0f, 2000.0f ),
[EncounterKind.Custom] = DemoPlacement( 400.0f, 1000.0f, 2100.0f )
};
}
private static PlacementProfile DemoPlacement( float minimum, float ideal, float maximum )
{
return new PlacementProfile
{
MinimumDistance = minimum,
IdealDistance = ideal,
MaximumDistance = maximum,
MinimumThreatSeparation = 90.0f,
DistanceWeight = 1.0f,
CapacityWeight = 0.3f,
SeparationWeight = 0.35f,
PathDistanceWeight = 0.15f,
IngressWeight = 0.1f,
FallbackMode = PlacementFallbackMode.AnyReachableHidden
};
}
}