PaintImpactSystem.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace PaintballBaddies;
/// <summary>Shared bounded paint decals; the generated mask is packed into alpha once.</summary>
public sealed class PaintImpactSystem : Component
{
public const int Capacity = 96;
public const float SplashScale = 1.15f;
public const int DropletCapacity = 48;
public int ActiveDroplets => droplets.Count(x => x.Age < x.Lifetime);
private readonly List<Droplet> droplets = new();
private int dropletCursor;
private sealed class Droplet
{
public GameObject Object;
public ModelRenderer Renderer;
public Vector3 Velocity;
public float Age, Lifetime, Radius;
}
public const float Duration = 75;
public int ActiveCount => marks.Count;
internal Decal OldestDecal => marks.TryPeek(out var mark) ? mark.Decal : null;
private static Texture splash;
private const string SplashPath = "textures/paint/paint_splash_irregular_v2.png";
private static string loadedSplashPath;
internal static Texture SplashTexture { get { PrepareTextures(); return splash; } }
private static Texture wetSurface;
private readonly Queue<Mark> marks = new();
private int sequence;
private sealed class Mark { public GameObject Object; public Decal Decal; public Color Tint; public float Age; }
public static PaintImpactSystem Find(Scene scene) => scene.GetAllComponents<PaintImpactSystem>().FirstOrDefault();
protected override void OnStart() => PrepareTextures();
private static void PrepareTextures()
{
if (loadedSplashPath == SplashPath && splash is { IsValid: true } && wetSurface is { IsValid: true }) return;
using var source=Bitmap.CreateFromBytes(FileSystem.Mounted.ReadAllBytes(SplashPath).ToArray());
using var mask=source.Resize(512,512);
var pixels=mask.GetPixels();
// A mask is data: preserve its coverage, use white RGB for runtime team tint.
for(int i=0;i<pixels.Length;i++) pixels[i]=Color.White.WithAlpha(pixels[i].r);
mask.SetPixels(pixels);splash=mask.ToTexture();
using var rmo=new Bitmap(1,1);
rmo.Clear(new Color(.25f,0,1,1));wetSurface=rmo.ToTexture();
loadedSplashPath = SplashPath;
}
public void Spawn(SceneTraceResult hit,Color tint)
{
if(!MultiplayerSession.Online){SpawnLocal(hit,tint);return;}
if(!MultiplayerSession.Authority || !hit.Hit)return;
if(ArenaShield.Find(hit.GameObject) is {} shield){shield.AddPaint(hit,tint);NetworkEffects.Sound("sounds/paintball/impact.sound",hit.HitPosition,System.Guid.Empty);return;}
if(PaintballCombatant.Find(hit.GameObject) is {} actor)
{
(actor.Components.Get<CharacterPaint>() ?? actor.Components.Create<CharacterPaint>()).Add(hit,tint);
NetworkEffects.Sound("sounds/paintball/impact.sound",hit.HitPosition,System.Guid.Empty);return;
}
MultiplayerSession.Find(Scene)?.AddWorldPaint(hit.HitPosition,hit.Normal,tint);
}
internal void SpawnLocal(SceneTraceResult hit,Color tint,bool freshImpact=true,float age=0)
{
if(!hit.Hit)return;
if(freshImpact)Sound.Play("sounds/paintball/impact.sound",hit.HitPosition);
PrepareTextures();
if(freshImpact)SpawnDroplets(hit.HitPosition,hit.Normal,tint);
if(ArenaShield.Find(hit.GameObject) is {} shield){shield.AddPaint(hit,tint);return;}
if(ArenaShield.Find(hit.GameObject) is null && PaintballCombatant.Find(hit.GameObject) is {} actor)
{
var clothing=actor.Components.Get<CharacterPaint>() ?? actor.Components.Create<CharacterPaint>();
clothing.Add(hit,tint);
return;
}
var rendered=Scene.Trace.Ray(hit.HitPosition+hit.Normal*2,hit.HitPosition-hit.Normal*16)
.WithSurfaceMeshes().UsePhysicsWorld(!Game.IsEditor).WithoutTags("paintball_debris").Run();
if(rendered.Hit && rendered.GameObject==hit.GameObject)hit=rendered;
// Recycle the oldest native decal at capacity instead of rebuilding its
// GameObject, component and definition for every sustained-fire impact.
Mark mark=marks.Count>=Capacity ? marks.Dequeue() : null;
if(mark is not null && (!mark.Object.IsValid() || !mark.Decal.IsValid()))
{
mark.Object?.Destroy();mark=null;
}
if(mark is null)
{
var fresh=new GameObject(true,"Paint splash decal"){NetworkMode=NetworkMode.Never};
var component=fresh.Components.Create<Decal>();
component.Decals=new List<DecalDefinition> { new() { ColorTexture=splash,RoughMetalOcclusionTexture=wetSurface,Width=1,Height=1,ColorMix=1 } };
mark=new Mark { Object=fresh,Decal=component };
}
var go=mark.Object;
go.Parent=hit.GameObject.IsValid() ? hit.GameObject : null;
go.WorldScale=Vector3.One;
go.WorldPosition=hit.HitPosition+hit.Normal*.35f;
go.WorldRotation=Rotation.LookAt(-hit.Normal);
var decal=mark.Decal;
float size=(5.5f+(sequence%5)*.45f)*SplashScale;
decal.Size=new Vector2(size,size);decal.Depth=8f;
decal.Rotation=(sequence++*137.508f)%360;decal.ColorTint=tint;
decal.AttenuationAngle=.4f;decal.LifeTime=0;decal.Transient=false;
mark.Tint=tint;mark.Age=age;
marks.Enqueue(mark);
}
private void SpawnDroplets(Vector3 position, Vector3 normal, Color tint)
{
var basis = Rotation.LookAt(normal);
for (int i=0;i<6;i++)
{
Droplet drop;
if (droplets.Count < DropletCapacity)
{
var go = new GameObject(false,"Paint impact droplet"){NetworkMode=NetworkMode.Never};
go.WorldPosition=position;
go.WorldScale=new Vector3(.0035f);
go.Tags.Add("paintball_debris");
drop = new Droplet { Object=go, Renderer=go.Components.Create<ModelRenderer>() };
drop.Renderer.Model=Model.Load("models/dev/sphere.vmdl");
droplets.Add(drop);
}
else drop=droplets[dropletCursor++ % DropletCapacity];
float angle=(sequence*2.399963f+i*1.047198f);
var radial=basis.Right*MathF.Cos(angle)+basis.Up*MathF.Sin(angle);
drop.Age=0;drop.Lifetime=.22f+(i%3)*.055f;
drop.Radius=.0035f+(i%3)*.0015f;
drop.Velocity=normal*(24+i*3)+radial*(30+i*5);
drop.Object.Enabled=false;
drop.Object.WorldPosition=position+normal*.5f;
drop.Object.WorldScale=Vector3.One*drop.Radius;
drop.Renderer.Tint=tint;
drop.Object.Enabled=true;
}
}
protected override void OnUpdate()
{
foreach(var drop in droplets)
{
if(drop.Age>=drop.Lifetime)continue;
drop.Age+=Time.Delta;
if(drop.Age>=drop.Lifetime) { drop.Object.Enabled=false; continue; }
drop.Object.WorldPosition+=drop.Velocity*Time.Delta+Vector3.Down*(90*Time.Delta*Time.Delta);
drop.Velocity+=Vector3.Down*(180*Time.Delta);
float tail=((drop.Lifetime-drop.Age)/.1f).Clamp(0,1);
drop.Object.WorldScale=Vector3.One*(drop.Radius*tail);
}
foreach(var mark in marks)
{
mark.Age+=Time.Delta;
if(mark.Decal.IsValid() && mark.Age>Duration-12)
mark.Decal.ColorTint=mark.Tint.WithAlpha(((Duration-mark.Age)/12).Clamp(0,1));
}
while(marks.TryPeek(out var first) && (first.Age>=Duration || !first.Object.IsValid()))
marks.Dequeue().Object?.Destroy();
}
public void Clear()
{
while(marks.Count>0)marks.Dequeue().Object?.Destroy();
foreach(var drop in droplets)drop.Object?.Destroy();
droplets.Clear();dropletCursor=0;
}
protected override void OnDestroy() => Clear();
}