things/Unit.cs

Unit entity class for the game, derived from Thing. It manages unit state like health/armor, flashing, flinching, jumping, repel/explosion velocities, hitstop, shaking, status handling, and spawns VFX/prefabs and RPCs to sync effects across network.

NetworkingFile Access
using Sandbox;
using Sandbox.Diagnostics;
using System;

public enum UnitFlashType { PlayerDmg, EnemyDmg, SelfDmg, Heal, BossDmg }

public partial class Unit : Thing
{
	[Property] public SkinnedModelRenderer ModelRenderer { get; set; }
	public virtual float HpPercent => 0f;
	public virtual bool ShowHealthbar => false;
	public virtual float HealthbarWidth => 650f; // todo: override for certain minibosses
	public virtual float HealthbarOffset => 100f;
	public virtual float HealthbarOpacity => 1f;
	public virtual float HealthbarArmorOpacity => 1f;

	[Sync] public bool IsDying { get; set; }

	[Sync] public int Armor { get; set; }
	[Sync] public RealTimeSince RealTimeSinceArmorChanged { get; protected set; }

	public bool IsFlashing { get; protected set; }
	protected TimeSince _timeSinceFlash;
	protected RealTimeSince _realTimeSinceFlash;
	protected float _flashTime;

	public bool IsFlinching { get; set; }
	public TimeSince _timeSinceLastFlinch { get; set; }
	private float _flinchTime;

	public TimeSince TimeSinceJump { get; set; }
	public float JumpTotalTime { get; set; }
	public float JumpMaxHeight { get; set; }
	public Vector2 JumpStartPos { get; set; }
	public Vector2 JumpTargetPos { get; set; }

	public Vector2 RepelVelocity { get; private set; }
	public float MaxRepelVelocity { get; set; }
	public Vector2 ExplosionVelocity { get; set; }

	public float RepelDeceleration { get; set; }
	public float ExplosionDeceleration { get; set; }

	public float Height { get; set; }

	public float AnimSpeedModifier { get; set; }

	public virtual bool IsInanimate => false;
	public virtual bool CanBeTargeted => true;

	public bool HitstopActive { get; set; }
	protected TimeSince _timeSinceHitstop;
	protected float _hitstopTime;
	public virtual bool CanHitstop => true;

	public float HitstopOldPlaybackSpeed { get; set; }

	public bool IsShaking { get; set; }
	private RealTimeSince _realTimeSinceShake;
	protected float _shakeStrengthStart;
	protected float _shakeStrengthEnd;
	private float _shakeTime;
	private EasingType _shakeEasingType;

	public virtual bool IsBoss => false;

	/// <summary>
	/// Multiplied by Height to find the y-pos of status particles (ignore if 0)
	/// </summary>
	public virtual float ParticleYPosOverride => 0f;
	public virtual float StunParticleYPosOverride => 0f;

	protected override void OnStart()
	{
		base.OnStart();

		RepelDeceleration = 12.9f;
		ExplosionDeceleration = 2f;
		MaxRepelVelocity = 500f;

		if ( Collider is CapsuleCollider capsuleCollider )
			Height = (capsuleCollider.End.z - capsuleCollider.Start.z) * SpawnScale.z;
		else if ( Collider is SphereCollider sphereCollider )
			Height = sphereCollider.Radius * 2f;

		AnimSpeedModifier = 1f;

		if(ShowHealthbar)
		{
			var healthbarGo = GameObject.Clone( "prefabs/unit_healthbar.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var healthbar = healthbarGo.GetComponent<UnitHealthbar>();
			//healthbar.Unit = this;
		}
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"TimeScale: {TimeScale}", new global::Transform( WorldPosition ) );

		if ( IsShaking )
			HandleShaking();

		if ( HitstopActive )
		{
			OnHitstopUpdate();

			if ( _timeSinceHitstop > _hitstopTime )
				HitstopEnd();
		}

		if ( IsProxy )
			return;

		if( !HitstopActive )
		{
			if ( RepelVelocity.LengthSquared > 0f )
				HandleRepelFromThings();

			if ( ExplosionVelocity.LengthSquared > 0f )
				HandleExplosionVelocity();

			if ( IsInTheAir )
				HandleJumping();
		}

		if ( Manager.Instance.IsGameOver )
			return;

		HandleStatuses( Time.Delta );
	}

	void HandleRepelFromThings()
	{
		WorldPosition += (Vector3)RepelVelocity * Time.Delta;
		RepelVelocity *= Math.Max( 1f - Time.Delta * RepelDeceleration * Manager.Instance.GlobalFrictionModifier, 0f );
	}

	public void AddRepelVelocity( Vector2 vel )
	{
		RepelVelocity += vel;

		if(RepelVelocity.LengthSquared > MaxRepelVelocity * MaxRepelVelocity)
			RepelVelocity = RepelVelocity.Normal * MaxRepelVelocity;
	}

	public void ResetRepelVelocity()
	{
		RepelVelocity = Vector2.Zero;
	}

	void HandleExplosionVelocity()
	{
		WorldPosition += (Vector3)ExplosionVelocity * (1f / Weight) * Time.Delta;
		ExplosionVelocity *= Math.Max( 1f - Time.Delta * ExplosionDeceleration * Manager.Instance.GlobalFrictionModifier, 0f );
	}

	void HandleJumping()
	{
		if( TimeSinceJump > JumpTotalTime )
		{
			JumpFinishRpc();
		}
		else
		{
			var pos = Vector2.Lerp( JumpStartPos, JumpTargetPos, Utils.Map( TimeSinceJump, 0f, JumpTotalTime, 0f, 1f, EasingType.Linear ) );
			var zPos = Utils.MapReturn( TimeSinceJump, 0f, JumpTotalTime, 0f, JumpMaxHeight, EasingType.QuadOut );

			WorldPosition = new Vector3( pos.x, pos.y, zPos );
		}
	}

	[Rpc.Broadcast]
	public void FlashRpc( float time, UnitFlashType flashType )
	{
		Flash( time, flashType );
	}	

	public virtual void Flash( float time, UnitFlashType flashType )
	{
		//if ( IsFlashing )
		//	return;

		IsFlashing = true;

		_timeSinceFlash = 0f;
		_realTimeSinceFlash = 0f;
		_flashTime = time;
	}

	protected virtual void HandleFlashing()
	{
		var timeSince = Manager.Instance.IsPaused ? _timeSinceFlash.Relative : _realTimeSinceFlash.Relative;
		if ( IsFlashing && timeSince > _flashTime )
			StopFlashing();
	}

	protected virtual void StopFlashing()
	{
		IsFlashing = false;
	}

	public virtual void Flinch( float time, Vector2 dir )
	{
		if( time > 0f )
		{
			IsFlinching = true;
			_timeSinceLastFlinch = 0f;
			_flinchTime = time;
		}
	}

	protected virtual void HandleFlinching()
	{
		if ( _timeSinceLastFlinch > _flinchTime )
			StopFlinching();
	}

	public virtual void StopFlinching()
	{
		IsFlinching = false;

	}

	[Rpc.Broadcast(NetFlags.Reliable)]
	public void JumpRpc( Vector2 targetPos, float height, float lifetime )
	{
		Jump( targetPos, height, lifetime );
	}

	protected virtual void Jump( Vector2 targetPos, float height, float lifetime )
	{
		if ( IsInTheAir )
			return;

		IsInTheAir = true;

		JumpStartPos = Position2D;
		JumpTargetPos = Manager.Instance.ClampPosToBounds( targetPos );

		if ( IsProxy )
			return;
		
		TimeSinceJump = 0f;
		JumpTotalTime = lifetime;
		JumpMaxHeight = height;

		Velocity = Vector2.Zero;
	}

	[Rpc.Broadcast]
	public void JumpFinishRpc()
	{
		JumpFinish();
	}

	public virtual void CancelJump()
	{
		if ( !IsInTheAir )
			return;

		IsInTheAir = false;
		WorldPosition = WorldPosition.WithZ( 0f );

		if ( IsProxy )
			return;

		Velocity = Vector2.Zero;
		RepelVelocity = Vector2.Zero;
		ExplosionVelocity = Vector2.Zero;
	}

	public virtual void JumpFinish()
	{
		IsInTheAir = false;

		GameObject.Clone( "prefabs/effects/cloud.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( ((Vector3)JumpTargetPos).WithZ( 10f ) ) } );

		// todo: sfx

		if ( IsProxy )
			return;

		WorldPosition = new Vector3( JumpTargetPos.x, JumpTargetPos.y, 0f );

		Velocity = Vector2.Zero;
		RepelVelocity = Vector2.Zero;
		ExplosionVelocity = Vector2.Zero;
	}

	protected override void OnOutOfBounds( Direction direction )
	{
		base.OnOutOfBounds( direction );

		if ( direction == Direction.Left )
		{
			RepelVelocity = new Vector2( Math.Abs( RepelVelocity.x ), RepelVelocity.y );
			ExplosionVelocity = new Vector2( Math.Abs( ExplosionVelocity.x ), ExplosionVelocity.y );
		}
		else if ( direction == Direction.Right )
		{
			RepelVelocity = new Vector2( -Math.Abs( RepelVelocity.x ), RepelVelocity.y );
			ExplosionVelocity = new Vector2( -Math.Abs( ExplosionVelocity.x ), ExplosionVelocity.y );
		}
		else if ( direction == Direction.Down )
		{
			RepelVelocity = new Vector2( RepelVelocity.x, Math.Abs( RepelVelocity.y ) );
			ExplosionVelocity = new Vector2( ExplosionVelocity.x, Math.Abs( ExplosionVelocity.y ) );
		}
		else if ( direction == Direction.Up )
		{
			RepelVelocity = new Vector2( RepelVelocity.x, -Math.Abs( RepelVelocity.y ) );
			ExplosionVelocity = new Vector2( ExplosionVelocity.x, -Math.Abs( ExplosionVelocity.y ) );
		}
	}

	[Rpc.Owner]
	public void AddExplosionVelocity( Vector2 vel )
	{
		ExplosionVelocity += vel;
	}

	public override void Colliding( Thing other, float percent, float dt )
	{
		base.Colliding( other, percent, dt );

		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
			UnitStatuses.Values.ElementAt( i ).Colliding( other, percent, dt );
	}

	public virtual void SetTimeScale( float timeScale )
	{
		AnimSpeedModifier = timeScale;
		TimeScale = timeScale;
	}


	[Rpc.Broadcast]
	public void HitstopRpc( float time )
	{
		
	}

	public void Hitstop( float time )
	{
		if ( IsBoss )
			time *= 0.66f;

		if ( HitstopActive || _timeSinceHitstop < 0.1f || time < 0.001f )
			return;

		HitstopActive = true;
		_timeSinceHitstop = 0f;
		_hitstopTime = time;
		//_hitstopOldPlaybackSpeed

		OnHitstopStart();
	}

	protected virtual void OnHitstopStart()
	{

	}

	protected virtual void OnHitstopUpdate()
	{

	}

	public void HitstopEnd()
	{
		HitstopActive = false;
		OnHitstopEnd();
	}

	protected virtual void OnHitstopEnd()
	{

	}

	[Rpc.Broadcast]
	public void ShakeRpc( float startStrength, float endStrength, float time, EasingType easingType = EasingType.Linear )
	{
		Shake( startStrength, endStrength, time, easingType );
	}

	public void Shake( float startStrength, float endStrength, float time, EasingType easingType = EasingType.Linear )
	{
		IsShaking = true;
		_realTimeSinceShake = 0f;
		_shakeStrengthStart = startStrength;
		_shakeStrengthEnd = endStrength;
		_shakeTime = time;
		_shakeEasingType = easingType;
	}

	void HandleShaking()
	{
		if ( _realTimeSinceShake > _shakeTime )
			StopShaking();
		else
			UpdateShaking( Utils.Map( _realTimeSinceShake, 0f, _shakeTime, _shakeStrengthStart, _shakeStrengthEnd, _shakeEasingType ) );
	}

	protected virtual void UpdateShaking( float strength )
	{
		
	}

	protected virtual void StopShaking()
	{
		IsShaking = false;
	}

	[Rpc.Owner]
	public void GainArmorRpc( int amount )
	{
		GainArmor( amount );
	}

	public void GainArmor( int amount )
	{
		Assert.True( !IsProxy );

		AdjustArmor( amount, Vector2.Zero );
	}

	public void LoseArmor( int amount, Vector2 dir, bool playSfx = true )
	{
		AdjustArmor( -amount, dir, playSfx );
	}

	void AdjustArmor( int amount, Vector2 flinchDir, bool playSfx = true )
	{
		if ( IsProxy || amount == 0 )
			return;

		Armor = Math.Clamp( Armor + amount, 0, 999 );
		RealTimeSinceArmorChanged = 0f;

		ArmorVfx( amount, flinchDir, playSfx );

		if( amount > 0 && this is Player player )
		{
			player.AddResultsStat( ResultStat.ArmorGained, 1 );
		}
	}

	[Rpc.Broadcast]
	public void ArmorVfx( int amount, Vector2 flinchDir, bool playSfx = false )
	{
		var isPlayer = this is Player;

		if( isPlayer )
		{
			float size = Utils.Map( Math.Abs( amount ), 1, 15, 1.25f, 2.5f, EasingType.Linear );
			var color = amount > 0 ? new Color( 0.7f, 0.7f, 0.7f ) : new Color( 0.65f, 0.5f, 0.5f );
			var str = $"{(amount > 0 ? "+" : "-")}{MathF.Abs( amount )}⛊";
			Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 65f ), str, color, size, amount > 0 ? FloaterType.ArmorGain : FloaterType.ArmorLose );
		}

		if ( amount < 0 )
		{
			ArmorFlinch( amount, flinchDir );
		}

		//FlashHeal( 0.12f );

		if ( playSfx )
		{
			int armorSfxMax = isPlayer ? 50 : 300;

			if ( amount > 0 )
				Manager.Instance.PlaySfxNearby( "armor_gain", Position2D, pitch: Utils.Map( amount, 1, 10, 0.8f, 1f, EasingType.Linear ) * Utils.Map( Armor, 0, 50, 0.8f, 1.1f, EasingType.Linear ) * Game.Random.Float( 0.97f, 1.03f ), volume: Utils.Map( amount, 1, 10, 0.85f, 1f ), maxDist: 320f );
			else
				Manager.Instance.PlaySfxNearby( "armor_hit", Position2D, pitch: Utils.Map( Armor, armorSfxMax, 0, 0.8f, 1f, EasingType.Linear ) * Game.Random.Float( 0.97f, 1.03f ), volume: Utils.Map( amount, 1, 10, 1.2f, 1.6f ), maxDist: 350f );
		}
	}

	protected virtual void ArmorFlinch( int amount, Vector2 flinchDir )
	{

	}

	public void CreateTeleportParticle( Vector2 pos, float radius, Color color, float lifetime, bool useRealTime = false )
	{
		var zPos = 10f * Game.Random.Float( 0.95f, 1.05f );
		var angle = 90f + Game.Random.Float( -10f, 10f );
		var particleGo = GameObject.Clone( $"prefabs/effects/blink.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, zPos ), new Angles( 90f, angle, 0f ) ) } );
		var particleEffect = particleGo.GetComponent<ParticleEffect>();
		particleEffect.Tint = color;
		particleEffect.Lifetime = lifetime;
		var particleSpriteRenderer = particleGo.GetComponent<ParticleSpriteRenderer>();
		particleSpriteRenderer.Scale = radius;

		var particleGo2 = GameObject.Clone( $"prefabs/effects/blink2.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, zPos ), new Angles( 90f, 90f, 0f ) ) } );
		var particleEffect2 = particleGo2.GetComponent<ParticleEffect>();

		if ( useRealTime )
		{
			particleEffect.Timing = ParticleEffect.TimingMode.RealTime;
			particleEffect2.Timing = ParticleEffect.TimingMode.RealTime;
		}
	}
}