Death/Blood.cs

Static helper that spawns and animates blood decals (pools, smears, splats) as unnetworked scene objects. Places decals on traced ground hits or attached to a vehicle, grows pools over time, and fades and destroys every stain after 180 seconds.

namespace HorizonRP;

/// <summary>
/// Le sang : des décalcomanies posées sur chaque machine, sans réseau. Chacune voit le même corps
/// au même endroit, à peu près : c'est du décor. Flaques et traînées sont les nôtres
/// (outils/sang/flaque.py, decals/horizon/) : les éclaboussures de s&box, sous un corps,
/// couvraient des mètres de gouttelettes.
/// - sous un inconscient, une seule flaque qui s'étale lentement (RoleplayPlayer.Death.cs) ;
/// - si l'on traîne le corps, des traînées dans le sens du mouvement, et une nouvelle flaque où il s'arrête ;
/// - un choc violent avec un véhicule : une giclée au sol et une sur la carrosserie (attachée au
///   véhicule, elle le suit).
/// Tout disparaît au bout de trois minutes, en s'estompant.
/// </summary>
public static class Blood
{
	public const float Lifetime = 180f;
	const float FadeTime = 12f;

	public enum Kind { Pool, Smear, Splat }

	static string[] Files( Kind kind ) => kind switch
	{
		Kind.Pool => new[] { "decals/horizon/flaque_01.decal", "decals/horizon/flaque_02.decal", "decals/horizon/flaque_03.decal" },
		Kind.Smear => new[] { "decals/horizon/trainee_01.decal", "decals/horizon/trainee_02.decal" },
		_ => new[] { "decals/blood/blood_splatter_01.decal", "decals/blood/blood_splatter_02.decal", "decals/blood/blood_splatter_03.decal", "decals/blood/blood_splatter_04.decal" },
	};

	/// <summary>
	/// La taille d'une décalcomanie est un multiple de celle de sa définition (32 pouces) : pas des
	/// pouces. Et la flaque n'occupe que 72 % de sa texture. Une taille demandée en pouces (le
	/// diamètre de la tache) devient ici l'échelle à donner.
	/// </summary>
	static float ScaleFor( Kind kind, float inches ) => inches / 32f / (kind == Kind.Pool ? 0.72f : 1f);

	public sealed class Stain
	{
		public GameObject Go;
		public Decal Decal;
		public RealTimeSince Age;
		public float From, To, GrowSeconds;
		/// <summary>Hauteur / largeur de la décalcomanie (une traînée est longue et étroite).</summary>
		public float Aspect = 1;
		public Vector3 Position;
	}

	static readonly List<Stain> stains = new();

	/// <summary>
	/// Une tache sur la surface sous <paramref name="position"/> (vers le bas) ou devant elle.
	/// <paramref name="growTo"/> &gt; <paramref name="size"/> : elle s'étale en <paramref name="growSeconds"/>.
	/// </summary>
	/// <param name="size">Le diamètre en pouces (la longueur pour une traînée).</param>
	/// <param name="along">Pour une traînée : le sens du mouvement (elle s'allonge dans ce sens).</param>
	/// <param name="width">Pour une traînée : sa largeur en pouces, quelle que soit sa longueur.</param>
	public static Stain Place( Kind kind, Vector3 position, Vector3 normal, float size, float growTo = 0, float growSeconds = 0, GameObject parent = null, Vector3 along = default, float width = 14f )
	{
		if ( Application.IsHeadless )
			return null;
		var files = Files( kind );
		var def = ResourceLibrary.Get<DecalDefinition>( files[Random.Shared.Int( 0, files.Length - 1 )] );
		if ( def is null )
			return null;

		var go = new GameObject( true, "Sang" );
		go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		go.WorldPosition = position + normal * 2f;
		// La décalcomanie projette vers l'avant : vers la surface. Tournée au hasard autour d'elle,
		// sauf une traînée, dont la longueur suit le mouvement.
		// (La longueur de la texture est l'axe « droite » de la décalcomanie : son « haut » est donc
		// posé en travers du mouvement.)
		go.WorldRotation = kind == Kind.Smear && along.Length > 0.01f
			? Rotation.LookAt( -normal, Vector3.Cross( normal, along ).Normal )
			: Rotation.LookAt( -normal ) * Rotation.FromRoll( Random.Shared.Float( 0, 360 ) );
		if ( parent.IsValid() )
			go.SetParent( parent, true );

		var decal = go.Components.Create<Decal>();
		decal.Decals = new List<DecalDefinition> { def };
		size = ScaleFor( kind, size );
		growTo = ScaleFor( kind, growTo );
		// Le trait occupe 36 % de la hauteur de sa texture : la hauteur qui donne la largeur voulue.
		var aspect = kind == Kind.Smear ? width / 32f / 0.36f / MathF.Max( 0.01f, size ) : 1f;
		decal.Size = new Vector2( size, size * aspect );
		decal.Depth = 10f;
		decal.Transient = false;

		var stain = new Stain { Go = go, Decal = decal, Age = 0, From = size, To = MathF.Max( size, growTo ), GrowSeconds = growSeconds, Position = position, Aspect = aspect };
		stains.Add( stain );
		return stain;
	}

	/// <summary>Une tache posée sur le sol sous un point (rien si le sol est trop loin).</summary>
	public static Stain OnGround( Kind kind, Scene scene, Vector3 at, float size, float growTo = 0, float growSeconds = 0, GameObject ignore = null, Vector3 along = default, float width = 14f )
	{
		var trace = scene.Trace.Ray( at + Vector3.Up * 24, at + Vector3.Down * 120 ).WithoutTags( "player", "trigger", "ragdoll" );
		if ( ignore.IsValid() )
			trace = trace.IgnoreGameObjectHierarchy( ignore );
		var tr = trace.Run();
		return tr.Hit ? Place( kind, tr.HitPosition, tr.Normal, size, growTo, growSeconds, along: along, width: width ) : null;
	}

	/// <summary>Chaque image, sur chaque machine : les flaques s'étalent, les vieilles taches s'estompent puis partent.</summary>
	public static void Tick()
	{
		for ( var i = stains.Count - 1; i >= 0; i-- )
		{
			var s = stains[i];
			if ( !s.Go.IsValid() || !s.Decal.IsValid() || s.Age > Lifetime )
			{
				s.Go?.Destroy();
				stains.RemoveAt( i );
				continue;
			}
			var size = s.From;
			if ( s.To > s.From && s.GrowSeconds > 0 )
			{
				// Vite au début, puis de plus en plus lentement, comme une flaque qui s'étend.
				var t = Math.Clamp( s.Age / s.GrowSeconds, 0, 1 );
				size = s.From + (s.To - s.From) * (1 - (1 - t) * (1 - t));
			}
			// Les dernières secondes, elle sèche : elle rétrécit un peu en pâlissant.
			var left = Lifetime - s.Age;
			if ( left < FadeTime )
			{
				var f = Math.Clamp( left / FadeTime, 0, 1 );
				size *= 0.7f + 0.3f * f;
				s.Decal.ColorTint = Color.White.WithAlpha( f );
			}
			if ( MathF.Abs( s.Decal.Size.x - size ) > 0.001f )
				s.Decal.Size = new Vector2( size, size * s.Aspect );
		}
	}
}