CitizenPlayerPresentation.cs
using Sandbox;
using Sandbox.Citizen;
namespace PaintballBaddies;
/// <summary>Opt-in native character presentation driven by existing gameplay components.</summary>
public sealed class CitizenPlayerPresentation : Component
{
[Property] public string ModelPath { get; set; } = "models/characters/viper_citizen_neck_review/viper_graph.vmdl";
[Property] public string OutfitPath { get; set; } = "models/gear/citizen_curvy_suit_review/suit.vmdl";
[Property] public bool UseRosterSuit { get; set; } = true;
private float shieldPoseWeight;
private CharacterEquipment equipment;
private int outfitIndex=-1;
public string EquippedSuit => equipment?.Outfit?.Model?.Name;
public string EquippedHelmet => equipment?.Helmet?.Model?.Name;
[Property] public string GlovesPath { get; set; } = "models/gear/citizen_female_gloves/gloves.vmdl";
[Property] public string BootsPath { get; set; } = "models/citizen_clothes/shoes/boots/models/boots_m_human.vmdl";
private PlayerController player;
private PaintballMarker weapon;
private CoverController cover;
private VaultController vault;
[Property] public string HelmetPath { get; set; } = "models/gear/citizen_closed_helmet_review/helmet.vmdl";
private GameObject subject, marker, support, previousAnchor;
private SkinnedModelRenderer body, previousBody;
private bool previousVisible;
private CitizenLocomotion locomotion;
private CitizenMarkerGrip grip;
private CitizenPodReload pod;
private readonly CitizenReloadClock clock = new();
private bool reloading, cancelled, gesture;
private float time, posture;
private GameObject raisedHand;
private Transform coverHand;
private bool coverHandCaptured;
private float coverAimSettled;
private ArenaShield previousShield;
private float holdTransition;
private bool previousStandingSheltered;
private bool previousCoverPeek;
public float CoverGunLift { get; private set; }
private Transform cancelHand;
private Rotation cancelPod;
public bool IsReady => body.IsValid() && body.Model.IsValid();
public SkinnedModelRenderer FootstepRenderer => body;
public bool PodHeld => pod.IsValid() && pod.podHeld;
protected override void OnStart()
{
player = Components.Get<PlayerController>();
weapon = Components.Get<PaintballMarker>();
cover = Components.Get<CoverController>();
vault = Components.Get<VaultController>();
if (!player.IsValid() || !weapon.IsValid()) return;
previousBody = player.Renderer;
previousVisible = previousBody.IsValid() && previousBody.Enabled;
subject = new GameObject(GameObject) { NetworkMode=NetworkMode.Never, Name = "Citizen player presentation" };
subject.WorldPosition = player.WorldPosition;
subject.WorldRotation = Rotation.FromYaw(player.EyeAngles.yaw);
body = subject.Components.Create<SkinnedModelRenderer>();
body.Model = Model.Load(ModelPath);
body.UseAnimGraph = true;
equipment=subject.Components.Create<CharacterEquipment>();
equipment.Configure(body, OutfitPath, GlovesPath, BootsPath, HelmetPath);
subject.Components.Create<CitizenSuitFollowThrough>().Configure(body,equipment.Outfit);
locomotion = subject.Components.Create<CitizenLocomotion>();
locomotion.Configure(body, CitizenAnimationHelper.HoldTypes.Rifle);
grip = subject.Components.Create<CitizenMarkerGrip>();
pod = subject.Components.Create<CitizenPodReload>();
pod.CreateGear();
marker = new GameObject(subject) { Name = "VX-9 native player presentation" };
marker.Components.Create<ModelRenderer>().Model = Model.Load("models/weapons/vx9_speed_feed/vx9_speed_feed.vmdl");
support = new GameObject(subject) { Name = "Marker support hand" };
previousAnchor = weapon.PresentationAnchor;
weapon.PresentationAnchor = marker;
weapon.ReloadStarted += StartReload;
weapon.ReloadCancelled += CancelReload;
if (previousBody.IsValid()) previousBody.Enabled = false;
if (weapon.ReloadRemaining > 0) StartReload();
}
private void StartReload()
{
reloading=true;cancelled=false;gesture=false;time=.8f;
clock.Reset();grip.ResetRecovery();pod.ResetCycle();
}
private void CancelReload()
{
if (!reloading || cancelled || !body.IsValid()) return;
cancelled=true;
if (body.TryGetBoneTransform("hand_L",out var hand)) cancelHand=subject.WorldTransform.ToLocal(hand);
if (pod.refillPod.IsValid()) cancelPod=subject.WorldRotation.Inverse*pod.refillPod.WorldRotation;
}
protected override void OnUpdate()
{
if (!IsReady || !player.IsValid() || !weapon.IsValid()) return;
if(subject.Parent!=GameObject)subject.Parent=GameObject;
if(UseRosterSuit && Components.Get<RosterSelection>() is { } roster && outfitIndex!=roster.Selected)
{
var model=Model.Load(CitizenRosterAssets.Suit(roster.Selected));
if(model.IsValid() && equipment.Outfit.IsValid())
{
var helmet=Model.Load(CitizenRosterAssets.Helmet(roster.Selected));
if(helmet.IsValid() && equipment.Helmet.IsValid())
{ equipment.Outfit.Model=model;equipment.Helmet.Model=helmet;outfitIndex=roster.Selected; }
}
}
if (previousBody.IsValid()) previousBody.Enabled=false;
var heldShield=Scene.GetAllComponents<ArenaShield>().FirstOrDefault(x=>x.Owner.IsValid()&&x.Owner.GameObject==GameObject);
if(heldShield!=previousShield){previousShield=heldShield;holdTransition=.4f;}
holdTransition=System.MathF.Max(0,holdTransition-Time.Delta);
bool shieldActive=heldShield is not null && !heldShield.Stowed;
shieldPoseWeight+=((shieldActive ? 1 : 0)-shieldPoseWeight)*(1-System.MathF.Exp(-12*Time.Delta));
body.Set("pb_shield_carry",shieldPoseWeight);
bool coverPeek=cover is {Attached:true,Peeking:true};
if(coverPeek!=previousCoverPeek){previousCoverPeek=coverPeek;holdTransition=.4f;coverHandCaptured=false;}
bool standingSheltered=cover is {Attached:true,Peeking:false,LowCover:false};
if(standingSheltered!=previousStandingSheltered){previousStandingSheltered=standingSheltered;holdTransition=.4f;}
bool oneHanded=shieldActive || standingSheltered;
locomotion.Animation.HoldType=standingSheltered ? CitizenAnimationHelper.HoldTypes.HoldItem : shieldActive ? CitizenAnimationHelper.HoldTypes.Pistol : CitizenAnimationHelper.HoldTypes.Rifle;
locomotion.Animation.Handedness=oneHanded ? CitizenAnimationHelper.Hand.Right : CitizenAnimationHelper.Hand.Both;
var sheltered=cover is { Attached:true, Peeking:false };
var vaulting=vault?.IsVaulting==true;
var duck=vaulting || player.IsDucking ? 1f : 0f;
body.Set("special_movement_states",cover?.Sliding==true ? 3 : 0);
var facing=cover?.Sliding==true ? Rotation.LookAt(cover.SlideDirection) : sheltered ? Rotation.LookAt(cover.LowCover ? -cover.Normal : cover.Normal) : Rotation.FromYaw(player.EyeAngles.yaw);
// The view can reverse instantly, but the body needs a short pivot.
// Bound its angular speed so native aim springs and feet can follow.
var currentYaw=subject.WorldRotation.Angles().yaw;
var yawStep=System.MathF.IEEERemainder(facing.Angles().yaw-currentYaw,360)*(1-System.MathF.Exp(-9*Time.Delta));
subject.WorldRotation=Rotation.FromYaw(currentYaw+yawStep.Clamp(-360*Time.Delta,360*Time.Delta));
if (reloading) time=clock.Sample(time,true,cancelled,grip.Returning,pod.podHeld,weapon.ReloadRemaining,weapon.ReloadProgress,Time.Delta);
var release=reloading && time>=.8f && time<3.2f;
var velocity=vaulting ? (player.WorldPosition-subject.WorldPosition)/System.MathF.Max(Time.Delta,.001f) : (IsProxy ? Components.Get<NetworkPawn>()?.Motion ?? player.Velocity : player.Velocity);
locomotion.UpdateVault(vaulting,vault?.Progress ?? 0,Time.Delta);
locomotion.UpdateMotion(velocity,IsProxy ? velocity : player.WishVelocity,((IsProxy ? Components.Get<NetworkPawn>()?.Grounded==true : player.IsOnGround) || cover?.Sliding==true || sheltered) && !vaulting,duck,0,
sheltered ? subject.WorldRotation : Rotation.FromYaw(player.EyeAngles.yaw),
release || sheltered ? 0 : player.EyeAngles.pitch,sheltered,Time.Delta,rootPosition:player.WorldPosition);
var recovery=((time-3.2f)/.6f).Clamp(0,1);
recovery=recovery*recovery*(3-2*recovery);
posture+=((reloading ? duck*(1-recovery) : 0)-posture)*(1-System.MathF.Exp(-24*Time.Delta));
body.Set("pb_reload_posture",posture);
grip.UpdateGrip(body,1,release,Time.Delta);
if (reloading && !cancelled && !gesture && time>=1) { body.Set("b_reload",true);gesture=true; }
if (grip.Attach(body,marker,CitizenMarkerGrip.MarkerOffset,Angles.Zero))
{
grip.UpdateSupport(body,locomotion.Animation,marker,support,new Vector3(1.5f,1,0),release,
reloading && time>=3.2f ? (time-3.2f)/.4f : -1);
pod.Apply(body,locomotion.Animation,marker,support,grip.SupportRotation,grip.Grip,time,
duck,release,cancelled,clock.CancellationElapsed,cancelHand,cancelPod,Time.Delta);
if(!reloading)UpdateCoverGunLift(vaulting || holdTransition>0 || shieldActive);
if(standingSheltered)locomotion.Animation.IkLeftHand=null;
heldShield?.ApplyGrip(body,locomotion.Animation);
}
subject.WorldPosition=player.WorldPosition;
Components.Get<CloseCombat>()?.ApplyPresentation(body,locomotion.Animation,marker);
pod.SetCarryVisible(!weapon.UnlimitedAmmo || reloading);
if (reloading)
{
time+=Time.Delta;
if (time>=4.5f && !pod.podHeld) reloading=false;
}
}
private void UpdateCoverGunLift(bool busy)
{
var bodyYaw=subject.WorldRotation.Angles().yaw;
float yawGap=System.MathF.Abs(System.MathF.IEEERemainder(player.EyeAngles.yaw-bodyYaw,360));
bool eligible=!busy && yawGap<=45 && (weapon.Aiming || cover is { Attached:true, Peeking:true }) && cover is not {Attached:true,Peeking:false};
if(!eligible)
{
CoverGunLift=0;coverHandCaptured=false;coverAimSettled=0;
locomotion.Animation.IkRightHand=null;
return;
}
// Follow Citizen's bounded aim frame. The camera can reverse immediately,
// but dragging a hand through that reversal pulls the arm behind the torso.
var aim=Rotation.FromYaw(bodyYaw+locomotion.AimYaw)*Rotation.FromPitch(locomotion.AimPitch);
var frame=new Transform(player.WorldPosition,aim);
if(!coverHandCaptured)
{
// Capture the native rifle pose after the pivot has caught up, rather
// than preserving a transient turn pose for the rest of the aim hold.
coverAimSettled=yawGap<=12 ? coverAimSettled+Time.Delta : 0;
locomotion.Animation.IkRightHand=null;
if(coverAimSettled<.15f)return;
if(!body.TryGetBoneTransform("hand_R",out var hand))return;
coverHand=frame.ToLocal(hand);coverHandCaptured=true;
}
var baseHand=frame.ToWorld(coverHand);
var muzzle=weapon.Muzzle-Vector3.Up*CoverGunLift;
float desired=0;
// Only lift enough to clear nearby cover. Keep the original shoulder pose
// and let native Citizen arm IK retain elbows and the animated grip.
float minimum=(player.EyePosition.z-5-muzzle.z).Clamp(0,12);
for(float lift=minimum;lift<=12;lift+=2)
{
var from=muzzle+Vector3.Up*lift;
var barrel=Scene.Trace.Sphere(.7f,from,from+aim.Forward*80)
.IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").WithSurfaceMeshes().Run();
var reach=Scene.Trace.Sphere(.7f,player.EyePosition,from)
.IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").WithSurfaceMeshes().Run();
if(!barrel.Hit && !reach.Hit){desired=lift;break;}
}
CoverGunLift+=(desired-CoverGunLift)*(1-System.MathF.Exp(-10*Time.Delta));
if(!raisedHand.IsValid())raisedHand=new GameObject(subject){Name="Cover raised marker hand"};
raisedHand.WorldPosition=baseHand.Position+Vector3.Up*CoverGunLift;
raisedHand.WorldRotation=baseHand.Rotation;
locomotion.Animation.IkRightHand=raisedHand;
}
protected override void OnDestroy()
{
if (weapon.IsValid())
{
weapon.ReloadStarted-=StartReload;weapon.ReloadCancelled-=CancelReload;
if (weapon.PresentationAnchor==marker) weapon.PresentationAnchor=previousAnchor;
}
if (previousBody.IsValid()) previousBody.Enabled=previousVisible;
if (subject.IsValid()) subject.Destroy();
}
}