CharacterPaint.cs
using Sandbox;
namespace PaintballBaddies;
/// <summary>The last twelve paint impacts, following native animated bone objects.</summary>
public sealed class CharacterPaint : Component
{
public const int Capacity=12;
private readonly Queue<GameObject> marks=new();
public int Count=>marks.Count;
public string LastBone { get; private set; }
public Vector3 LastPosition { get; private set; }
private int sequence;
[Sync(SyncFlags.FromHost)] public string PaintHistory {get;set;}="";
private readonly List<PaintRecord> records=new();
private int receivedSequence;
private string receivedHistory;
private sealed class PaintRecord
{
public int Id {get;set;}
public string Bone {get;set;}
public Vector3 Point {get;set;}
public Rotation Facing {get;set;}
public Color Tint {get;set;}
}
private SkinnedModelRenderer RenderBody=>Components.Get<CitizenPlayerPresentation>()?.FootstepRenderer
?? Components.Get<CitizenOpponentPresentation>()?.FootstepRenderer
?? Components.Get<ArenaOpponent>()?.Body ?? Components.Get<PlayerController>()?.Renderer;
protected override void OnUpdate()
{
if(MultiplayerSession.Authority || receivedHistory==PaintHistory || !RenderBody.IsValid())return;
if(string.IsNullOrEmpty(PaintHistory)){Clear();receivedHistory=PaintHistory;return;}
var entries=Json.Deserialize<List<PaintRecord>>(PaintHistory);
foreach(var entry in entries)if(entry.Id>receivedSequence){if(!Apply(entry,RenderBody))return;receivedSequence=entry.Id;}
receivedHistory=PaintHistory;
}
private bool Apply(PaintRecord record,SkinnedModelRenderer body)
{
var parent=body.GetBoneObject(record.Bone);if(!parent.IsValid())return false;
while(marks.Count>=Capacity)marks.Dequeue()?.Destroy();
var go=new GameObject(parent){Name="Clothing paint splat",NetworkMode=NetworkMode.Never};
go.LocalPosition=record.Point;go.LocalRotation=record.Facing;
var decal=go.Components.Create<Decal>();
decal.Decals=new(){new(){ColorTexture=PaintImpactSystem.SplashTexture,Width=1,Height=1,ColorMix=1}};
decal.Size=new Vector2(5.5f,5.5f)*PaintImpactSystem.SplashScale;decal.Depth=5;
decal.ColorTint=record.Tint;decal.Rotation=(record.Id*137.508f)%360;
decal.LifeTime=0;decal.Transient=false;decal.AttenuationAngle=.3f;
marks.Enqueue(go);return true;
}
public void Add(SceneTraceResult hit,Color tint)
{
var body=Components.Get<CitizenPlayerPresentation>()?.FootstepRenderer
?? Components.Get<CitizenOpponentPresentation>()?.FootstepRenderer
?? Components.Get<ArenaOpponent>()?.Body ?? Components.Get<PlayerController>()?.Renderer;
if(!body.IsValid())return;
// A movement capsule/box is broader than the clothing. Project onto
// the visible surface before attaching the paint, when available.
var surface=Scene.Trace.Ray(hit.HitPosition+hit.Normal*12,hit.HitPosition-hit.Normal*24)
.WithSurfaceMeshes().UsePhysicsWorld(!Game.IsEditor).WithoutTags("paintball_debris").Run();
if(surface.Hit && PaintballCombatant.Find(surface.GameObject)?.GameObject==GameObject)hit=surface;
// IK targets are control points, not cloth bones. Some are closest to
// the seat or thigh during a stride and made paint swing with a hand.
string boneName=null;float nearest=float.MaxValue;
foreach(var bone in body.Model.Bones.AllBones)
{
var name=bone.Name;
if(name!="pelvis" && name!="head" && name!="neck_0" && !name.StartsWith("spine_")
&& name!="leg_upper_L" && name!="leg_upper_R" && name!="leg_lower_L" && name!="leg_lower_R"
&& name!="arm_upper_L" && name!="arm_upper_R" && name!="arm_lower_L" && name!="arm_lower_R"
&& name!="hand_L" && name!="hand_R" && name!="foot_L" && name!="foot_R")continue;
if(!body.TryGetBoneTransform(name,out var pose))continue;
float distance=(pose.Position-hit.HitPosition).LengthSquared;
if(distance<nearest){nearest=distance;boneName=name;}
}
if(body.TryGetBoneTransform("pelvis",out var pelvis))
{
var local=body.WorldTransform.PointToLocal(hit.HitPosition);
var hip=body.WorldTransform.PointToLocal(pelvis.Position);
if(System.MathF.Abs(local.z-hip.z)<7 && (local-hip).WithZ(0).Length<12
&& hit.GameObject?.Name!="Protective gloves")boneName="pelvis";
}
if(boneName is null)return;
var parent=body.GetBoneObject(boneName);
if(!parent.IsValid())return;
if(!MultiplayerSession.Authority)return;
var record=new PaintRecord{Id=++sequence,Bone=boneName,Point=parent.WorldTransform.PointToLocal(hit.HitPosition+hit.Normal*.2f),
Facing=parent.WorldRotation.Inverse*Rotation.LookAt(-hit.Normal),Tint=tint};
Apply(record,body);
if(MultiplayerSession.Online)
{
records.Add(record);if(records.Count>Capacity)records.RemoveAt(0);
PaintHistory=Json.Serialize(records);
}
LastBone=boneName;LastPosition=hit.HitPosition;
}
public void Clear(){while(marks.Count>0)marks.Dequeue()?.Destroy();records.Clear();receivedSequence=0;receivedHistory="";if(MultiplayerSession.Authority)PaintHistory="";}
protected override void OnDestroy()=>Clear();
}