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