CupTiltController.cs
using System;
using Sandbox;

/// <summary>
/// Momentum-based wrist tilt. Left/right click accelerate opposite
/// directions, drag bleeds velocity off over time, plus PC interference
/// noise that RoundManager drives.
/// </summary>
public sealed class CupTiltController : Component
{
	[Property] public GameObject HandTarget { get; set; }
	[Property] public GameObject ChestAnchor { get; set; }

	[Property] public bool InvertDirection { get; set; } = false;

	// Freeze is now driven by RoundManager, not a keybind.
	[Property] public bool FreezeTilt { get; set; } = false;

	[Property] public float TiltAccel { get; set; } = 100.0f;
	[Property] public float Drag { get; set; } = 5.0f;
	[Property] public float MinTilt { get; set; } = -100.0f;
	[Property] public float MaxTilt { get; set; } = 180.0f;

	// PC interference, set by RoundManager each round.
	public float InterferenceIntensity { get; set; } = 0.0f;

	// STRENGTH = raw magnitude of the random velocity kicks.
	[Property] public float NoiseStrength { get; set; } = 30.0f;

	// AGGRESSIVENESS = how often/fast the kicks change direction.
	[Property] public float NoiseAggressiveness { get; set; } = 2.0f;

	private float _tiltVelocity;
	private float _tiltAngle;
	private Rotation _baseRotation;

	private float _noiseCurrent, _noiseTarget, _noiseTimer;

	public float TiltAngle => _tiltAngle;

	protected override void OnStart()
	{
		if ( HandTarget is not null )
			_baseRotation = HandTarget.Transform.Rotation;

		ResetToHome();
	}

	// Called by RoundManager between rounds to snap back to upright.
	public void ResetToHome()
	{
		_tiltAngle = 0;
		_tiltVelocity = 0;
		_noiseCurrent = 0;
		_noiseTarget = 0;
	}

	protected override void OnUpdate()
	{
		if ( HandTarget is null || ChestAnchor is null )
			return;

		if ( !FreezeTilt )
		{
			if ( Input.Down( "attack1" ) )
				_tiltVelocity += TiltAccel * Time.Delta;

			if ( Input.Down( "attack2" ) )
				_tiltVelocity -= TiltAccel * Time.Delta;

			if ( InterferenceIntensity > 0 )
			{
				_noiseTimer += Time.Delta;

				var retargetInterval = 1f / MathF.Max( 0.01f, NoiseAggressiveness );

				if ( _noiseTimer >= retargetInterval )
				{
					_noiseTimer = 0;
					_noiseTarget = Game.Random.Float( -1f, 1f );
				}

				_noiseCurrent = MathX.Lerp( _noiseCurrent, _noiseTarget, Time.Delta * NoiseAggressiveness );
				_tiltVelocity += _noiseCurrent * InterferenceIntensity * NoiseStrength * Time.Delta;
			}

			if ( _tiltVelocity > 0 )
				_tiltVelocity = MathF.Max( 0, _tiltVelocity - Drag * Time.Delta );
			else if ( _tiltVelocity < 0 )
				_tiltVelocity = MathF.Min( 0, _tiltVelocity + Drag * Time.Delta );

			_tiltAngle += _tiltVelocity * Time.Delta;

			if ( _tiltAngle > MaxTilt )
			{
				_tiltAngle = MaxTilt;
				_tiltVelocity = 0;
			}
			else if ( _tiltAngle < MinTilt )
			{
				_tiltAngle = MinTilt;
				_tiltVelocity = 0;
			}
		}

		var tiltAngleApplied = InvertDirection ? -_tiltAngle : _tiltAngle;
		var tiltAxis = ChestAnchor.Transform.Rotation.Right;
		var tiltRotation = Rotation.FromAxis( tiltAxis, tiltAngleApplied );

		HandTarget.Transform.Rotation = tiltRotation * _baseRotation;
	}
}