Visual and gameplay effects for the Director NPC and his reward. DirectorFx provides static helpers to set the Director model body, mark/visualize charged zombies, play roar daze and camera shake, and spawn a summoning beam. DirectorReward grants a random perk to the local player who is the killer. DirectorCharge is an empty marker component used to mark charged zombies. DirectorLamp is a component that creates and updates point lights for the Director's lamp and eyes, handles flicker, spark effects, color change when low health, and cleanup on destroy.
using Sandbox;
using System;
namespace NZombies;
/// <summary>
/// The Director's look, on EVERY machine (2026-10-06): his stage lamp and eyes (`DirectorLamp`), his body swapping to the
/// zombified one when he rages, his arrival out of a beam, the zombies he charges with electricity, and his roar's daze.
/// The host decides; `NZNet.DirectorBody` / `DirectorCharge` / `DirectorRoar` carry it.
/// </summary>
public static class DirectorFx
{
/// <summary>His lamp's colour: upstream's blue (100, 150, 255), orange (255, 150, 0) under half health.</summary>
public static Color LampBlue => new( 100f / 255f, 150f / 255f, 1f );
public static Color LampOrange => new( 1f, 150f / 255f, 0f );
/// <summary>
/// His body on THIS machine: choice 0 the calm George, 1 the zombified one he rages in (upstream's `SetBodygroup(0, …)`) —
/// a bodygroup does not replicate after the spawn (`NZNet.DirectorBody`). ⚠️ AND HIS LEGS' CAP WITH IT (2026-10-06): raging at
/// five times his sprint, every machine lets his legs cycle up to `DirectorBoss.RageAnimRate` (`ZombieAI.MaxAnimRateOverride`),
/// a client's puppet included, or he would glide.
/// </summary>
public static void SetBody( GameObject director, int choice )
{
if ( !director.IsValid() ) return;
var r = director.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
if ( r.IsValid() ) r.SetBodyGroup( "body", Math.Clamp( choice, 0, 1 ) );
var ai = director.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
if ( ai.IsValid() ) ai.MaxAnimRateOverride = choice == 1 ? DirectorBoss.RageAnimRate : 0f;
}
/// <summary>
/// A zombie he charged (upstream's `SetWaterBuff`), on THIS machine: electricity crawling over it (Avogadro's crackle, whose
/// bones are the walker's). The health is the host's (`DirectorBoss.ElectricBuff`).
/// </summary>
public static void Charge( GameObject zombie )
{
if ( !zombie.IsValid() ) return;
zombie.Components.GetOrCreate<DirectorCharge>();
zombie.Components.GetOrCreate<AvogadroCrackle>();
}
/// <summary>
/// A roar on THIS machine: a shake, and THIS machine's own player dazed if inside <paramref name="radius"/> — upstream's
/// `NZSonicBlind`, the Shrieker's daze here, which only the owner can put on its movement.
/// </summary>
public static void Roar( Vector3 at, float radius, float daze, float shake )
{
if ( shake > 0f ) CameraShake.Punch( at, shake, MathF.Max( radius * 2f, 1000f ) );
var me = NZPlayer.Local;
if ( !me.IsValid() || me.IsDown || me.IsOutOfRound || daze <= 0f ) return;
if ( me.WorldPosition.Distance( at ) > radius ) return;
SonicDaze.Apply( me, daze );
}
/// <summary>The summoning beam on THIS machine: bolts down out of the sky onto where he stands (upstream's `summon_beam`).</summary>
public static void Beam( Vector3 at )
{
for ( int i = 0; i < 3; i++ )
{
var top = at + Vector3.Up * Game.Random.Float( 420f, 640f ) + Vector3.Random.WithZ( 0 ) * 40f;
LightningArc.Hang( top, at + Vector3.Up * Game.Random.Float( 10f, 70f ), null, null, LampBlue );
}
AvogadroFx.Flash( at + Vector3.Up * 40f, 1.2f );
}
}
/// <summary>The Director's perk for his killer, on THAT player's machine (`NZNet.DirectorReward`).</summary>
public static class DirectorReward
{
/// <summary>
/// THIS machine's player, if it is <paramref name="body"/>, gets a perk it lacks, at random, through the slot cap — upstream's
/// perk bottle. ⚠️ HERE BECAUSE PERKS LIVE ON THE OWNER'S MACHINE: `NZPlayer.Perks` is synced to no one (2026-10-06 fix — it
/// was granted on the host, where a client killer's perks are only a copy nobody reads).
/// </summary>
public static void Grant( Guid body )
{
var me = NZPlayer.Local;
if ( !me.IsValid() || me.GameObject.Id != body ) return;
if ( me.PerksFull ) { PowerupBannerState.Show( "NO FREE PERK SLOT" ); return; }
var choices = new System.Collections.Generic.List<PerkRegistry.Perk>();
foreach ( var k in PerkRegistry.All ) if ( !me.HasPerk( k.Id ) ) choices.Add( k );
if ( choices.Count == 0 ) return;
var perk = choices[Game.Random.Int( 0, choices.Count - 1 )];
if ( !me.GivePerk( perk.Id ) ) return;
PerkEffects.OnPerkGained( me, perk.Id );
PowerupBannerState.Show( $"FREE PERK: {perk.Name.ToUpperInvariant()}" );
NZSound.Play( NZSound.PerkVend, me.WorldPosition );
Log.Info( $"[nz-director] his perk: {perk.Name}" );
}
}
/// <summary>Marks a zombie the Director has charged (host and every machine), so he does not charge it twice.</summary>
public sealed class DirectorCharge : Component
{
}
/// <summary>
/// The Director's stage lamp and eyes, on EVERY machine: a flickering light at the lamp's mouth (upstream's `light_fx_tag`,
/// on the club's bone), blue until his health is under half, then orange (one way, upstream's `LowLightHP`); a spark at the
/// lamp now and then (upstream's `env_spark` on `light2_fx_tag`); and an orange glow at his glasses (upstream's eye sprites).
///
/// ⚠️ THE HEALTH IS READ THROUGH `ZombieAI.HealthNow`, which is the host's synced number on a client (`HealthNet`), so the
/// lamp turns on every machine at once without a message of its own.
/// </summary>
public sealed class DirectorLamp : Component
{
/// <summary>`light_fx_tag` and `light2_fx_tag`: offsets in `tag_weapon_right`'s frame, from the model's QC.</summary>
static Vector3 LampOffset => new( -29.81f, 12.92f, -5.27f );
static Vector3 SparkOffset => new( -17.61f, 12.92f, 6.92f );
/// <summary>`eyes`: in `j_head`'s frame.</summary>
static Vector3 EyesOffset => new( 4.1f, 4.35f, 0f );
SkinnedModelRenderer _body;
ZombieAI _ai;
PointLight _lamp, _eyes;
GameObject _lampGo, _eyesGo;
bool _orange, _off;
float _flicker, _nextFlicker, _nextSpark;
protected override void OnStart()
{
_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
_lampGo = new GameObject { Parent = GameObject, Name = "nz_director_lamp" };
_lampGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
_lamp = _lampGo.Components.Create<PointLight>();
_lamp.LightColor = DirectorFx.LampBlue;
_lamp.Radius = 200f;
_lamp.Shadows = false;
_eyesGo = new GameObject { Parent = GameObject, Name = "nz_director_eyes" };
_eyesGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
_eyes = _eyesGo.Components.Create<PointLight>();
_eyes.LightColor = new Color( 1f, 175f / 255f, 0f ) * 0.8f;
_eyes.Radius = 34f;
_eyes.Shadows = false;
}
/// <summary>Out for good (his removal at the end of his death).</summary>
public void TurnOff()
{
_off = true;
if ( _lamp.IsValid() ) _lamp.LightColor = Color.Black;
if ( _eyes.IsValid() ) _eyes.LightColor = Color.Black;
}
protected override void OnUpdate()
{
if ( _off || !_lamp.IsValid() || !_body.IsValid() ) return;
var now = Time.Now;
if ( !_orange && _ai.IsValid() && _ai.HealthMax > 0f && _ai.HealthNow > 0f && _ai.HealthNow / _ai.HealthMax < 0.5f )
_orange = true;
if ( now >= _nextFlicker )
{
_nextFlicker = now + Game.Random.Float( 0.05f, 0.14f );
_flicker = Game.Random.Float( 0.7f, 1.1f );
}
if ( _body.TryGetBoneTransform( "tag_weapon_right", out var club ) )
{
_lampGo.WorldPosition = club.PointToWorld( LampOffset );
_lamp.LightColor = (_orange ? DirectorFx.LampOrange : DirectorFx.LampBlue) * (3.5f * _flicker);
_lamp.Radius = 200f * MathF.Max( 0.1f, WorldScale.x );
if ( now >= _nextSpark )
{
_nextSpark = now + Game.Random.Float( 0.25f, 0.9f );
var at = club.PointToWorld( SparkOffset );
AvogadroSpark.Between( at, at + Vector3.Random.Normal * Game.Random.Float( 8f, 18f ), Game.Random.Float( 0.06f, 0.14f ), 0.12f );
}
}
if ( _eyes.IsValid() && _body.TryGetBoneTransform( "j_head", out var head ) )
_eyesGo.WorldPosition = head.PointToWorld( EyesOffset );
}
protected override void OnDestroy()
{
if ( _lampGo.IsValid() ) _lampGo.Destroy();
if ( _eyesGo.IsValid() ) _eyesGo.Destroy();
}
}