FoundryPaintWear.cs
using Sandbox;
using System;
using System.Collections.Generic;
namespace PaintballBaddies;
/// <summary>Persistent, deterministic dry paint from earlier venue matches.</summary>
public sealed class FoundryPaintWear : Component
{
private GameObject wear;
private static Texture drySurface;
private int count;
private static Texture[] splashes, grime;
private static Texture chips, chipNormal;
private float coverDelay;
private bool coverWearAdded;
protected override void OnStart()
{
wear = new GameObject { NetworkMode=NetworkMode.Never, Name = "Paint residue from previous matches" };
if ( drySurface is not { IsValid: true } )
{
using var bitmap = new Bitmap(1,1);
bitmap.Clear(new Color(.9f,0,1,1));
drySurface = bitmap.ToTexture();
}
PrepareWearTextures();
var random = new Random(71429);
float Between(float a,float b) => a + (float)random.NextDouble()*(b-a);
// Repeated shots cluster at human height along the backstop walls.
foreach(var side in new[]{-1f,1f})
for(int cluster=0;cluster<8;cluster++)
for(int mark=0;mark<9;mark++)
{
float x=-1020+cluster*290+Between(-48,48);
float z=Between(22,96);
TraceMark(new Vector3(x,side*770,z),new Vector3(x,side*840,z),Between(18,36),Between(0,360));
}
// End-wall firing backstops, below signage and skylights.
foreach(var side in new[]{-1f,1f})
for(int i=0;i<30;i++)
{
float y=Between(-680,680),z=Between(25,100);
TraceMark(new Vector3(side*1120,y,z),new Vector3(side*1210,y,z),Between(18,38),Between(0,360));
}
for(int i=0;i<40;i++)
{
float x=Between(-1000,1000),y=Between(-650,650);
TraceMark(new Vector3(x,y,8),new Vector3(x,y,-15),Between(15,30),Between(0,360));
}
// The enclosed annex carries wear from previous venue matches too.
for(int i=0;i<50;i++)
{
float x=Between(-1060,1060),z=Between(20,100);
TraceMark(new Vector3(x,1500,z),new Vector3(x,1550,z),Between(18,38),Between(0,360));
}
// Carry the venue's history along the annex flanks, not only its backstop.
foreach(var side in new[]{-1f,1f})
for(int cluster=0;cluster<6;cluster++)
for(int mark=0;mark<5;mark++)
{
float y=880+cluster*112+Between(-32,32),z=Between(20,88);
TraceMark(new Vector3(side*1120,y,z),new Vector3(side*1210,y,z),Between(12,30),Between(0,360));
}
for(int i=0;i<24;i++)
{
float x=Between(-1050,1050),y=Between(890,1480);
TraceMark(new Vector3(x,y,8),new Vector3(x,y,-15),Between(10,24),Between(0,360));
}
AddAgeWear();
Log.Info($"FOUNDRY_PAINT_WEAR marks={count} persistent=true");
}
protected override void OnUpdate()
{
if(coverWearAdded || (coverDelay+=Time.Delta)<1)return;
coverWearAdded=true;
var random=new Random(9317);
var before=count;
foreach(var cover in Scene.GetAllComponents<CoverSurface>().Where(x=>x.Components.Get<BoxCollider>().IsValid() || x.Components.Get<ModelCollider>()?.Model is { IsValid: true }).OrderBy(x=>x.WorldPosition.y>826 ? 0 : 1).ThenBy(x=>x.GameObject.Name).Take(40))
{
var box=cover.Components.Get<BoxCollider>();
var model=cover.Components.Get<ModelCollider>()?.Model;
var localCenter=box.IsValid() ? box.Center : model.Bounds.Center;
var localSize=box.IsValid() ? box.Scale : model.Bounds.Size;
var center=cover.WorldPosition+cover.WorldRotation*(localCenter*cover.WorldScale);
var extent=localSize*cover.WorldScale;
var radius=MathF.Max(extent.x,extent.y)*.75f+40;
for(int i=0;i<4;i++)
{
var angle=i*90+(float)random.NextDouble()*30;
var direction=Rotation.FromYaw(angle).Forward;
var point=center.WithZ(cover.WorldPosition.z+MathF.Min(cover.Top-cover.WorldPosition.z-5,15+(float)random.NextDouble()*25));
TraceMark(point+direction*radius,point,14+(float)random.NextDouble()*12,(float)random.NextDouble()*360,cover.GameObject);
// Smaller overlapping impacts break up isolated, evenly sized marks.
var offset=direction.Cross(Vector3.Up)*(8+(float)random.NextDouble()*12)+Vector3.Up*((float)random.NextDouble()*12-6);
TraceMark(point+offset+direction*radius,point+offset,8+(float)random.NextDouble()*10,(float)random.NextDouble()*360,cover.GameObject);
// Residue on the floor beside cover, never projected through the prop.
var floorPoint=center+direction*(MathF.Max(extent.x,extent.y)*.55f+10);
TraceMark(floorPoint.WithZ(7),floorPoint.WithZ(-8),10+(float)random.NextDouble()*12,(float)random.NextDouble()*360);
}
}
Log.Info($"FOUNDRY_COVER_WEAR marks={count-before} total={count} persistent=true");
}
private void TraceMark(Vector3 from,Vector3 to,float size,float angle,GameObject expected=null)
{
var hit=Scene.Trace.Ray(from,to).Run();
if(!hit.Hit || (expected is not null && hit.GameObject!=expected))return;
var go=new GameObject(wear){Name="Dried venue paint"};
go.WorldPosition=hit.HitPosition+hit.Normal*.2f;
go.WorldRotation=Rotation.LookAt(-hit.Normal);
var decal=go.Components.Create<Decal>();
decal.Decals=new List<DecalDefinition>{new(){ColorTexture=splashes[count%splashes.Length],RoughMetalOcclusionTexture=drySurface,Width=1,Height=1,ColorMix=1}};
var palette=new[]{new Color(.9f,.12f,.4f,.88f),new Color(.08f,.65f,.7f,.86f),new Color(.92f,.7f,.08f,.9f),new Color(.52f,.2f,.72f,.82f),new Color(.35f,.49f,.35f,.65f)};
decal.ColorTint=palette[count++%palette.Length];
// Cover uses conservative box colliders; recessed panels sit behind
// that hull and need a deeper projection than flat wall geometry.
decal.Size=new Vector2(size*2.3f,size*(count%3==0 ? 3.0f : 2.3f));decal.Depth=expected is null ? .7f : 8f;decal.Rotation=angle;
decal.AttenuationAngle=.4f;decal.LifeTime=0;decal.Transient=false;
}
private static Texture LoadWear(string name,bool mask)
{
using var source=Bitmap.CreateFromBytes(FileSystem.Mounted.ReadAllBytes($"textures/venue_wear/{name}.png").ToArray());
using var bitmap=source.Resize(512,512);
if(mask)
{
var pixels=bitmap.GetPixels();
for(int i=0;i<pixels.Length;i++)pixels[i]=Color.White.WithAlpha(pixels[i].a);
bitmap.SetPixels(pixels);
}
return bitmap.ToTexture();
}
private static void PrepareWearTextures()
{
if(splashes is not null && splashes[0].IsValid)return;
splashes=new[]{LoadWear("splash_1",true),LoadWear("splash_2",true),LoadWear("splash_3",true),LoadWear("splash_4",true)};
grime=new[]{LoadWear("grime_1",true),LoadWear("grime_2",true)};
chips=LoadWear("chipped_plaster_color",false);chipNormal=LoadWear("chipped_plaster_normal",false);
}
private void AgeMark(Vector3 from,Vector3 to,Vector2 size,Color tint,int variant,float angle=0)
{
var hit=Scene.Trace.Ray(from,to).WithSurfaceMeshes().UsePhysicsWorld(!Game.IsEditor).Run();if(!hit.Hit)return;
if(variant==2 && hit.GameObject.Name.Contains("column"))return;
var go=new GameObject(wear){Name=variant==2 ? "Aged chipped masonry" : "Venue grime and oxidation"};
go.WorldPosition=hit.HitPosition+hit.Normal*.15f;go.WorldRotation=Rotation.LookAt(-hit.Normal);
var decal=go.Components.Create<Decal>();
var definition=new DecalDefinition{ColorTexture=variant==2 ? chips : grime[variant%2],RoughMetalOcclusionTexture=drySurface,Width=1,Height=1,ColorMix=1};
if(variant==2)definition.NormalTexture=chipNormal;
decal.Decals=new List<DecalDefinition>{definition};decal.ColorTint=tint;
decal.Size=size;decal.Depth=1;decal.Rotation=angle;decal.AttenuationAngle=.4f;decal.LifeTime=0;decal.Transient=false;
}
private void AddAgeWear()
{
var rng=new Random(82016);float R(float a,float b)=>a+(float)rng.NextDouble()*(b-a);
// Wide, low-contrast traffic stains, with denser residue near lane edges.
for(int i=0;i<110;i++)
{
var point=new Vector3(R(-1110,1110),R(-760,1480),0);
AgeMark(point.WithZ(6),point.WithZ(-12),new Vector2(R(180,360),R(90,200)),new Color(.12f,.105f,.085f,.68f),i%2,R(0,360));
}
foreach(float side in new[]{-1f,1f})
for(int i=0;i<24;i++)
{
float x=R(-1100,1100),z=R(12,95);
AgeMark(new Vector3(x,side*770,z),new Vector3(x,side*840,z),new Vector2(R(15,36),R(12,28)),new Color(.65f,.62f,.57f,.8f),2,R(0,360));
AgeMark(new Vector3(x,side*770,18),new Vector3(x,side*840,18),new Vector2(R(90,160),45),new Color(.18f,.17f,.12f,.48f),i%2);
}
// Accumulated paint and boot smears in the practice firing lanes.
for(int i=0;i<55;i++)
{
var point=new Vector3(R(-900,900),R(-660,650),0);
TraceMark(point.WithZ(6),point.WithZ(-12),R(22,44),R(0,360));
}
// Oxidation is concentrated on actual metal columns and their feet.
foreach(var renderer in Scene.GetAllComponents<ModelRenderer>().Where(x=>x.GameObject.Name.Contains("column")))
{
var center=renderer.WorldPosition;
for(int side=0;side<4;side++)
{
var dir=Rotation.FromYaw(side*90).Forward;
var point=center.WithZ(center.z+R(20,110));
AgeMark(point+dir*55,point,new Vector2(30,70),new Color(.42f,.16f,.045f,.75f),side%2);
}
}
}
protected override void OnDestroy()=>wear?.Destroy();
}