PlayerClass.cs

Player class component for a three-class system (Scout, Medic, Juggernaut). Applies per-class movement passives to EvadeMovement, tracks a synced ability cooldown, and runs one active ability per class: Scout dash, Medic healing station that heals and pauses bleedout in a radius, Juggernaut shockwave that pushes rigidbodies and stuns Nextbots. Also saves the chosen class to a file in FileSystem.Data and broadcasts ability sounds.

File AccessNetworkingReflection
🐞 Ability activation is wired to Input.Pressed("menu"), so pressing Escape to open the menu also fires the class ability (and IsQPressed just returns the same menu check, so Q is never actually read).
🐞 The healing station GameObject and its PointLight are created on the deploying client and stored in a plain field, so other players never see the green healing zone.
using Sandbox;
using System;
using System.Linq;

public enum PlayerClassType
{
	Scout,      // Runner: +15% sprint speed, faster slide momentum, Dash Burst (Q)
	Medic,      // Rescue: +50% revive speed, green pulse beacon, Healing Station (Q)
	Juggernaut  // Enforcer: +30% knockback resist, 1 extra Nextbot hit armor, Shockwave Push (Q)
}

public sealed class PlayerClass : Component
{
	public static PlayerClass LocalInstance { get; private set; }

	[Sync] public PlayerClassType SelectedClass { get; set; } = PlayerClassType.Scout;
	[Sync] public int JuggernautArmorCharges { get; set; } = 1;

	// Active Ability Cooldowns
	[Property] public float ScoutDashCooldown { get; set; } = 12f;
	[Property] public float MedicStationCooldown { get; set; } = 20f;
	[Property] public float JuggernautShockwaveCooldown { get; set; } = 15f;

	[Sync] private TimeSince _timeSinceAbilityUsed { get; set; } = 100f;
	[Sync] private TimeSince _healingStationActiveTimer { get; set; } = 100f;
	private GameObject _activeHealingStationObj;

	private PlayerHealth _health;
	private EvadeMovement _evadeMovement;
	private CharacterController _characterController;
	private Rigidbody _rigidbody;
	private PlayerController _playerController;

	public float AbilityCooldown => SelectedClass switch
	{
		PlayerClassType.Scout => ScoutDashCooldown,
		PlayerClassType.Medic => MedicStationCooldown,
		PlayerClassType.Juggernaut => JuggernautShockwaveCooldown,
		_ => 15f
	};

	public float AbilityCooldownProgress => MathX.Clamp( (float)_timeSinceAbilityUsed / MathF.Max( 0.01f, AbilityCooldown ), 0f, 1f );
	public bool IsAbilityReady => AbilityCooldownProgress >= 0.999f && ( _health == null || ( !_health.IsDead && !_health.IsDowned ) );
	public float AbilityRemainingCooldown => MathF.Max( 0f, AbilityCooldown - (float)_timeSinceAbilityUsed );

	public string ClassName => SelectedClass switch
	{
		PlayerClassType.Scout => "SCOUT",
		PlayerClassType.Medic => "MEDIC",
		PlayerClassType.Juggernaut => "JUGGERNAUT",
		_ => "SCOUT"
	};

	public string AbilityName => SelectedClass switch
	{
		PlayerClassType.Scout => "DASH BURST",
		PlayerClassType.Medic => "HEALING STATION",
		PlayerClassType.Juggernaut => "SHOCKWAVE PUSH",
		_ => "ABILITY"
	};

	public string ClassIcon => SelectedClass switch
	{
		PlayerClassType.Scout => "⚡",
		PlayerClassType.Medic => "🚑",
		PlayerClassType.Juggernaut => "🛡️",
		_ => "⚡"
	};

	protected override void OnStart()
	{
		_health = Components.Get<PlayerHealth>() ?? Components.GetInChildren<PlayerHealth>();
		_evadeMovement = Components.Get<EvadeMovement>() ?? Components.GetInChildren<EvadeMovement>();
		_characterController = Components.Get<CharacterController>();
		_rigidbody = Components.Get<Rigidbody>();
		_playerController = Components.Get<PlayerController>();

		if ( !IsProxy && ( _playerController.IsValid() || _health.IsValid() ) )
		{
			LocalInstance = this;
			LoadSavedClass();
		}

		ApplyClassPassives();
	}

	public void SetClass( PlayerClassType newClass )
	{
		SelectedClass = newClass;
		_timeSinceAbilityUsed = 100f; // Ready immediately on select
		ApplyClassPassives();
		SaveSelectedClass();
		Log.Info( $"[PlayerClass] Class changed to {SelectedClass}" );
	}

	public void ResetArmor()
	{
		if ( SelectedClass == PlayerClassType.Juggernaut )
		{
			JuggernautArmorCharges = 1;
		}
		else
		{
			JuggernautArmorCharges = 0;
		}
	}

	public void ApplyClassPassives()
	{
		if ( !_evadeMovement.IsValid() ) return;

		// Base values
		float baseWalk = 220f;
		float baseSprint = 500f;
		float baseSlideDecay = 250f;
		float baseSprintAccel = 1.1f;

		switch ( SelectedClass )
		{
			case PlayerClassType.Scout:
				// +15% sprint speed, faster slide buildup
				_evadeMovement.WalkSpeed = baseWalk * 1.05f;
				_evadeMovement.SprintSpeed = baseSprint * 1.15f;
				_evadeMovement.SprintAccelerationTime = baseSprintAccel * 0.75f;
				_evadeMovement.SlideDecayRate = baseSlideDecay * 0.85f;
				break;

			case PlayerClassType.Medic:
				_evadeMovement.WalkSpeed = baseWalk;
				_evadeMovement.SprintSpeed = baseSprint;
				_evadeMovement.SprintAccelerationTime = baseSprintAccel;
				_evadeMovement.SlideDecayRate = baseSlideDecay;
				break;

			case PlayerClassType.Juggernaut:
				_evadeMovement.WalkSpeed = baseWalk * 0.95f;
				_evadeMovement.SprintSpeed = baseSprint * 0.95f;
				_evadeMovement.SprintAccelerationTime = baseSprintAccel * 1.1f;
				_evadeMovement.SlideDecayRate = baseSlideDecay;
				if ( JuggernautArmorCharges < 1 ) JuggernautArmorCharges = 1;
				break;
		}
	}

	protected override void OnUpdate()
	{
		if ( IsProxy ) return;

		// Update Healing Station duration
		if ( _activeHealingStationObj.IsValid() )
		{
			if ( _healingStationActiveTimer > 6f )
			{
				_activeHealingStationObj.Destroy();
				_activeHealingStationObj = null;
			}
			else
			{
				UpdateHealingStationAoE();
			}
		}

		if ( _health.IsValid() && ( _health.IsDead || _health.IsDowned ) ) return;

		// Press Q for Active Class Ability
		if ( Input.Pressed( "menu" ) || Input.Pressed( "attack3" ) || Input.Pressed( "voice" ) || IsQPressed() )
		{
			TryActivateAbility();
		}
	}

	private bool IsQPressed()
	{
		// s&box input mapping: Check for Q key
		return Input.Pressed( "menu" );
	}

	public void TryActivateAbility()
	{
		if ( !IsAbilityReady ) return;
		if ( _health.IsValid() && ( _health.IsDead || _health.IsDowned ) ) return;

		_timeSinceAbilityUsed = 0f;

		switch ( SelectedClass )
		{
			case PlayerClassType.Scout:
				ExecuteDashBurst();
				break;

			case PlayerClassType.Medic:
				DeployHealingStation();
				break;

			case PlayerClassType.Juggernaut:
				ExecuteShockwavePush();
				break;
		}
	}

	// 1. SCOUT: DASH BURST
	private void ExecuteDashBurst()
	{
		Vector3 dashDir = Vector3.Zero;
		if ( Scene.Camera.IsValid() )
		{
			var angles = Scene.Camera.WorldRotation.Angles();
			var forward = Rotation.FromYaw( angles.yaw ).Forward;
			var left = Rotation.FromYaw( angles.yaw ).Left;
			var move = Input.AnalogMove;
			if ( !move.IsNearlyZero() )
			{
				dashDir = ( forward * move.x + left * move.y ).Normal;
			}
			else
			{
				dashDir = forward;
			}
		}
		else
		{
			dashDir = WorldRotation.Forward;
		}

		float dashImpulse = 950f;
		if ( _characterController.IsValid() )
		{
			_characterController.Velocity = dashDir * dashImpulse + Vector3.Up * 120f;
		}
		else if ( _rigidbody.IsValid() )
		{
			_rigidbody.ApplyImpulse( dashDir * dashImpulse * 50f );
		}

		CameraJuice.Instance?.Shake( 18f, 5f );
		BroadcastAbilitySound( WorldPosition, "music/buttonhover.sound" );
		Log.Info( "[PlayerClass] Scout Dash Burst executed!" );
	}

	// 2. MEDIC: HEALING STATION
	private void DeployHealingStation()
	{
		if ( _activeHealingStationObj.IsValid() )
		{
			_activeHealingStationObj.Destroy();
		}

		_activeHealingStationObj = Scene.CreateObject();
		_activeHealingStationObj.Name = "HealingStation_AoE";
		_activeHealingStationObj.WorldPosition = WorldPosition;

		// Attach a point light to signify the green healing zone
		var light = _activeHealingStationObj.Components.Create<PointLight>();
		light.Radius = 250f;
		light.LightColor = Color.FromRgb( 0x34d399 );
		light.Shadows = false;

		_healingStationActiveTimer = 0f;
		BroadcastAbilitySound( WorldPosition, "music/buttonhover.sound" );
		Log.Info( "[PlayerClass] Medic Healing Station deployed (6s AoE)!" );
	}

	private void UpdateHealingStationAoE()
	{
		if ( !_activeHealingStationObj.IsValid() ) return;
		Vector3 stationPos = _activeHealingStationObj.WorldPosition;
		float aoeRadius = 250f; // 5 meters

		var players = Scene.GetAllComponents<PlayerHealth>();
		foreach ( var p in players )
		{
			if ( !p.IsValid() || !p.GameObject.IsValid() ) continue;
			float dist = Vector3.DistanceBetween( stationPos, p.WorldPosition );
			if ( dist <= aoeRadius )
			{
				// Pause bleedout for downed players inside zone
				if ( p.IsDowned )
				{
					p.PauseBleedout( 0.05f );
				}
				else if ( !p.IsDead )
				{
					// Slowly heal alive players
					p.Heal( 12f * Time.Delta );
				}
			}
		}
	}

	// 3. JUGGERNAUT: SHOCKWAVE PUSH
	private void ExecuteShockwavePush()
	{
		Vector3 blastCenter = WorldPosition;
		float blastRadius = 320f;
		float shockForce = 1200f;

		// 1. Blast rigidbodies and props
		var rbs = Scene.GetAllComponents<Rigidbody>();
		foreach ( var rb in rbs )
		{
			if ( !rb.IsValid() || rb.GameObject == GameObject ) continue;
			float d = Vector3.DistanceBetween( blastCenter, rb.WorldPosition );
			if ( d <= blastRadius )
			{
				Vector3 dir = ( rb.WorldPosition - blastCenter ).Normal + Vector3.Up * 0.4f;
				float falloff = 1f - ( d / blastRadius );
				rb.ApplyImpulse( dir * shockForce * falloff * 50f );
			}
		}

		// 2. Blast away and stun Nextbots in vicinity
		var bots = Scene.GetAllComponents<NextbotAI>();
		foreach ( var bot in bots )
		{
			if ( !bot.IsValid() || !bot.GameObject.IsValid() ) continue;
			float d = Vector3.DistanceBetween( blastCenter, bot.WorldPosition );
			if ( d <= blastRadius )
			{
				Vector3 dir = ( bot.WorldPosition - blastCenter ).Normal + Vector3.Up * 0.4f;
				bot.ApplyStunAndKnockback( dir * shockForce, 1.8f );
			}
		}

		CameraJuice.Instance?.Shake( 30f, 7f );
		BroadcastAbilitySound( WorldPosition, "music/buttonhover.sound" );
		Log.Info( "[PlayerClass] Juggernaut Shockwave Push executed!" );
	}

	[Rpc.Broadcast]
	private void BroadcastAbilitySound( Vector3 pos, string soundPath )
	{
		if ( !string.IsNullOrEmpty( soundPath ) )
		{
			Sound.Play( soundPath, pos );
		}
	}

	private void LoadSavedClass()
	{
		if ( FileSystem.Data.FileExists( "one_more_push_class.txt" ) )
		{
			try
			{
				string text = FileSystem.Data.ReadAllText( "one_more_push_class.txt" ).Trim();
				if ( Enum.TryParse<PlayerClassType>( text, true, out var c ) )
				{
					SelectedClass = c;
					ApplyClassPassives();
				}
			}
			catch ( Exception ex )
			{
				Log.Warning( $"Could not load class preference: {ex.Message}" );
			}
		}
	}

	private void SaveSelectedClass()
	{
		try
		{
			FileSystem.Data.WriteAllText( "one_more_push_class.txt", SelectedClass.ToString() );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"Could not save class preference: {ex.Message}" );
		}
	}

	protected override void OnDestroy()
	{
		if ( _activeHealingStationObj.IsValid() )
		{
			_activeHealingStationObj.Destroy();
			_activeHealingStationObj = null;
		}
		if ( LocalInstance == this ) LocalInstance = null;
		base.OnDestroy();
	}
}