Zombies/DirectorFx.cs

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.

File Access
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();
	}
}