Weapons/PhysGun/PhygunViewmodel.cs
public sealed class PhygunViewmodel : Component, Component.ExecuteInEditor
{
	[Property] public List<SpriteRenderer> TipSprites { get; set; }
	[Property] public ParticleEffect GlowEffect { get; set; }
	[Property] public ParticleEffect SparksEffect { get; set; }
	[Property] public Material TubeFxMaterial { get; set; }
	[Property] public Material BottleMaterial { get; set; }

	[Property] public bool BeamActive { get; set; }
	[Property] public Color GravTint { get; set; }
	[Property] public Color PhysTint { get; set; }

	float _tintFrac;
	Color _effectsTint;

	protected override void OnUpdate()
	{
		if ( GetComponentInParent<Physgun>() is Physgun physgun )
		{
			BeamActive = physgun.BeamActive;

			_tintFrac = MathX.Approach( _tintFrac, physgun.PullActive ? 1 : 0, Time.Delta * 5 );
			// Steep ease-in-out so the transition rushes through the midpoint
			var t = _tintFrac < 0.5f
				? 8f * _tintFrac * _tintFrac * _tintFrac * _tintFrac
				: 1f - 8f * (1f - _tintFrac) * (1f - _tintFrac) * (1f - _tintFrac) * (1f - _tintFrac);

			_effectsTint = Color.Lerp( PhysTint, GravTint, t );
		}
		else
		{
			_tintFrac = 0.0f;
			_effectsTint = PhysTint;
		}

		UpdateGlowEffect();
		UpdateTipSprites();
		UpdateTubeFx();
		UpdateSparks();
		UpdateBottleGlow();
	}

	float _scroll;
	float _scrollSpeed;
	float _scrollSpeedVel;

	void UpdateTubeFx()
	{
		if ( TubeFxMaterial is null ) return;

		// ideally we'd scroll the self illum on its own - but that's not an option.
		// we have g_vSelfIllumScrollSpeed but we can't scale that speed up and down, because it's multiplied by time internally.

		_scrollSpeed = MathX.SmoothDamp( _scrollSpeed, BeamActive ? 2.0f : 0.2f, ref _scrollSpeedVel, BeamActive ? 0.5f : 2.5f, Time.Delta );
		_scroll += _scrollSpeed * Time.Delta;

		TubeFxMaterial.Set( "g_vSelfIllumOffset", new Vector2( _scroll % 1, 0 ) );
		TubeFxMaterial.Set( "g_flSelfIllumBrightness", 3 * (_scrollSpeed + 1.5) );
		TubeFxMaterial.Set( "g_vSelfIllumTint", _effectsTint );
	}

	void UpdateBottleGlow()
	{
		if ( BottleMaterial is null ) return;

		float bounce = MathF.Sin( Time.Now * (BeamActive ? 45.0f : 3.0f) ) * 0.5f;

		BottleMaterial.Set( "g_vSelfIllumTint", _effectsTint );
		BottleMaterial.Set( "g_flSelfIllumBrightness", (BeamActive ? 6.0f : 1.5f) + bounce );
	}

	void UpdateTipSprites()
	{
		var mul = BeamActive ? 1.0f : 0.6f;

		foreach ( var sprite in TipSprites )
		{
			sprite.Enabled = true;
			sprite.Color = _effectsTint.WithAlpha( mul * Random.Shared.Float( 0.4f, 0.9f ) );
			sprite.Size = Random.Shared.Float( 6, 7 ) * mul;
		}
	}

	void UpdateGlowEffect()
	{
		if ( GlowEffect is null ) return;

		GlowEffect.Tint = _effectsTint;
		GlowEffect.Alpha = BeamActive ? 1.0f : 0.2f;
	}


	bool _wasActive;

	void UpdateSparks()
	{
		if ( !SparksEffect.IsValid() ) return;

		if ( BeamActive == _wasActive ) return;

		_wasActive = BeamActive;

		int count = BeamActive ? 20 : 3;

		for ( int i = 0; i < count; i++ )
		{
			var p = SparksEffect.Emit( SparksEffect.WorldPosition, i / (float)count );
			if ( p is null ) continue;
			p.Velocity = Vector3.Random * 100.0f + SparksEffect.WorldTransform.Forward * 30.0f;
		}
	}
}