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"/> > <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 );
}
}
}