A game Component that renders and manages a circular shockwave effect. It updates radius over time, applies damage/force to nearby non-proxy players once when the wave passes, and builds a SceneLineObject for the visual ring with wobble and scrolling texture.
using System;
using Sandbox;
/// <summary>
/// Shockwaves are created on client and can't hurt proxy players.
/// </summary>
public class Shockwave : Component
{
private SceneLineObject _so;
private float _shockwaveRadius;
private float _shockwaveTimer;
private Dictionary<Player, bool> _playerShockwaveChecks = new();
private List<Player> _shockwaveDamagedPlayers = new();
private float _wobbleRate;
private float _wobblePhase;
private float _scrollRate;
private int _numSegments;
public Enemy EnemySource { get; set; }
public EnemyType EnemyType { get; set; }
public float Damage { get; set; }
public float Radius { get; set; }
public float Lifetime { get; set; }
public float Force { get; set; }
public Gradient Gradient { get; set; }
public float ScrollSpeed { get; set; } = 1f;
public float SlowdownDuration { get; set; } = 0.5f;
public float WobbleIntensity { get; set; }
public float WobbleSpeed { get; set; } = 15f;
public float EmissiveScale { get; set; } = 4f;
[Property] public Texture LineTexture { get; set; }
protected override void OnStart()
{
base.OnStart();
if ( !Application.IsDedicatedServer )
{
_so = new SceneLineObject( Scene.SceneWorld );
_so.RenderingEnabled = false;
_so.Flags.IsOpaque = false;
_so.Flags.IsTranslucent = true;
_so.Flags.CastShadows = true;
_so.Material = Material.Load( "materials/shockwave.vmat" );
_so.Attributes.Set( "g_flEmissiveScale", EmissiveScale );
_so.Attributes.Set( "BaseTexture", LineTexture ?? Texture.White );
}
WobbleIntensity = Game.Random.Float( 6f, 10f );
float direction = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
_wobbleRate = Game.Random.Float( 0.15f, 1f ) * WobbleSpeed * direction;
_wobblePhase = Game.Random.Float( 0f, MathF.PI * 2f );
float scrollDirection = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
_scrollRate = Game.Random.Float( 0.6f, 1.4f ) * ScrollSpeed * scrollDirection;
_numSegments = Game.Random.Int( 70, 80 );
}
protected override void OnDisabled()
{
_so?.Delete();
_so = null;
}
protected override void OnUpdate()
{
base.OnUpdate();
var pos = (Vector2)WorldPosition;
_shockwaveTimer += Time.Delta;
float slowdownStart = Lifetime - SlowdownDuration;
// midRadius chosen so that constant decel from v0→0 over SlowdownDuration lands exactly at Radius
float midRadius = Radius * 2f * slowdownStart / (2f * slowdownStart + SlowdownDuration);
float v0 = midRadius / slowdownStart;
float naturalRadius;
if ( _shockwaveTimer <= slowdownStart )
{
naturalRadius = v0 * _shockwaveTimer;
}
else
{
float tau = _shockwaveTimer - slowdownStart;
naturalRadius = midRadius + v0 * tau * (1f - tau / (2f * SlowdownDuration));
}
_shockwaveRadius = naturalRadius * 0.92f;
if ( _shockwaveTimer > Lifetime )
{
GameObject.Destroy();
return;
}
else
{
foreach ( Player player in Manager.Instance.AlivePlayers )
{
if ( player.IsProxy )
continue;
bool withinShockwave = (player.Position2D - pos).LengthSquared < MathF.Pow( _shockwaveRadius, 2f );
if ( _playerShockwaveChecks.ContainsKey( player ) )
{
if ( withinShockwave != _playerShockwaveChecks[player] && !_shockwaveDamagedPlayers.Contains( player ) && player.TimeSinceTeleport > 0.1f && !player.IsInTheAir && !player.IsDead &&
_shockwaveTimer < Lifetime - SlowdownDuration * 0.5f )
{
var dir = (player.Position2D - pos).Normal;
var hitPos = player.Position2D - dir * player.Radius;
player.Damage( Damage, DamageType.Shockwave, hitPos, dir, upwardAmount: Game.Random.Float( 0f, 0.5f ), force: Force, ragdollForce: Force * 0.005f, EnemySource, EnemyType );
_shockwaveDamagedPlayers.Add( player );
}
_playerShockwaveChecks[player] = withinShockwave;
}
else
{
_playerShockwaveChecks.Add( player, withinShockwave );
}
}
}
if ( _so is null )
return;
_so.Transform = WorldTransform;
_so.StartCap = SceneLineObject.CapStyle.None;
_so.EndCap = SceneLineObject.CapStyle.None;
_so.Face = SceneLineObject.FaceMode.Camera;
_so.RenderingEnabled = true;
//_so.Attributes.Set( "BaseTexture", Texture.White );
float widthDecay = Utils.Map( _shockwaveTimer, 0f, Lifetime, 1.4f, 0.55f );
float baseWidth = (8f + Utils.FastSin( Time.Now * 16f ) * 1.5f) * widthDecay;
float alpha = MathF.Min( (0.8f + Utils.FastSin( Time.Now * 12f ) * 0.2f)
* Utils.Map( _shockwaveTimer, Lifetime - SlowdownDuration, Lifetime, 1f, 0f ), 1f );
float scrollOffset = (Time.Now * _scrollRate) % 1.0f;
float wobbleScale = _shockwaveRadius / Radius;
var center = WorldPosition;
_so.StartLine();
for ( int i = 0; i <= _numSegments; i++ )
{
float t = (float)i / _numSegments;
float angle = (1f - MathF.Cos( t * MathF.PI )) / 2f * MathF.PI * 2f;
float wobble = (MathF.Sin( angle * 5f + Time.Now * _wobbleRate + _wobblePhase ) * WobbleIntensity
+ MathF.Sin( angle * 11f + Time.Now * _wobbleRate * 1.7f + _wobblePhase * 1.37f ) * WobbleIntensity * 0.4f
+ MathF.Sin( angle * 7f + Time.Now * _wobbleRate * 0.9f + _wobblePhase * 2.1f ) * WobbleIntensity * 0.25f) * wobbleScale;
float r = _shockwaveRadius + wobble;
Vector3 pointPos = center + new Vector3( MathF.Cos( angle ) * r, MathF.Sin( angle ) * r, 15f );
float colorT = angle / (MathF.PI * 2f);
float sampledT = ((colorT + scrollOffset) % 1.0f + 1.0f) % 1.0f;
Color color = Gradient.Evaluate( sampledT ).WithAlpha( alpha );
float widthWobble = MathF.Sin( angle * 6f + Time.Now * _wobbleRate * 2f + _wobblePhase * 1.5f ) * WobbleIntensity * 0.4f * wobbleScale;
float width = MathF.Max( baseWidth + widthWobble, 1f );
_so.AddLinePoint( pointPos, WorldTransform.Up, color, width, colorT + scrollOffset );
}
_so.EndLine();
if ( IsProxy )
return;
}
}