OverdriveShotgun.cs
using Sandbox;
using System;
namespace PaintballBaddies;
public static class OverdriveShotgun
{
public const float Interval=.78f;
public const string SoundPath="sound/vidya/weapons/shotgun/shotgun_shoot.sound";
public static float IntervalFor(Scene scene,GameObject actor=null)=>MultiplayerSession.Find(scene)?.IsOverdrive==true ? (actor?.Components.Get<PaintballCombatant>()?.HasCrossbow==true ? .62f : actor.IsValid() && OverdriveLoadout.DualFor(actor) ? DualInterval : Interval) : PaintballFlight.ShotInterval;
// Dual pistols: quicker alternating shots, a tight three-pellet group each.
public const float DualInterval=.3f;
public static void LocalKick(Scene scene,GameObject shooter)
{
if(!scene.Camera.IsValid() || shooter.Components.Get<PlayerController>()?.IsProxy!=false)return;
if(shooter.Components.Get<PaintballCombatant>()?.HasCrossbow==true){scene.Camera.AddPunch(new Angles(-.65f,0,.1f),1,.12f,0);return;}
scene.Camera.AddShake(.8f,35,.13f);
scene.Camera.AddPunch(new Angles(-2,0,.3f),1,.17f,0);
}
public static void Fire(Scene scene,PaintballCombatant shooter,Vector3 origin,Vector3 aim,int seed)
{
var session=MultiplayerSession.Find(scene);
if(!MultiplayerSession.Authority || !shooter.IsValid() || !shooter.AcceptHits || session?.IsOverdrive!=true || !session.InRound
|| shooter.Components.Get<CloseCombat>()?.Incapacitated==true)return;
var eye=shooter.Components.Get<PlayerController>()?.EyePosition ?? shooter.WorldPosition+Vector3.Up*52;
// Keep real walls authoritative, but do not mistake an enemy overlapping
// the barrel for cover. Retract the launch to the eye for point-blank aim.
if(PaintballFlight.Trace(scene,eye,origin,shooter.GameObject,hitEnemies:false).Hit)return;
if((aim-eye).Length<100 || OverdriveEnemy.TraceIntact(scene.Trace.Ray(eye,origin)
.UsePhysicsWorld(false).UseHitboxes(true).WithTag("overdrive_enemy")).Hit)origin=eye;
if(shooter.HasCrossbow){SausageBolt.Fire(shooter,origin,aim);return;}
var rotation=Rotation.LookAt((aim-origin).Normal);
var damage=new Dictionary<(OverdriveEnemy,int),int>();
var hitPoints=new Dictionary<(OverdriveEnemy,int),Vector3>();
var concrete=new Dictionary<ConcreteChip,int>();
var crockery=new Dictionary<RestaurantBreakable,(float Damage,Vector3 Point)>();
var stacks=new Dictionary<OverdriveEnemy,Vector3>();
OverdriveEnemy primaryEnemy=null;
bool helper=shooter.Components.Get<NetworkBot>().IsValid();
var hitDrones=new HashSet<PopRiotTarget>();
var crashingAtStart=session.Scene.GetAllComponents<PopRiotTarget>().Where(t=>t.Crashing).ToHashSet();
bool hitSomething=false,metalHit=false;Vector3 impact=aim,bodyImpact=aim;
bool dual=OverdriveLoadout.DualFor(shooter.GameObject);
int pellets=dual ? 3 : 9;
var points=new Vector3[pellets];var normals=new Vector3[pellets];int surfaceMask=0;
for(int i=0;i<pellets;i++)
{
float radius=i==0 ? 0 : (dual ? .02f : .038f)*MathF.Sqrt(i/(float)(pellets-1));
float angle=i*2.399963f+seed*.71f;
var direction=(rotation.Forward+rotation.Right*MathF.Cos(angle)*radius+rotation.Up*MathF.Sin(angle)*radius).Normal;
var ray=scene.Trace.Ray(origin,origin+direction*1700).WithSurfaceMeshes().IgnoreGameObjectHierarchy(shooter.GameObject)
.WithoutTags("paintball_actor","paintball_debris","overdrive_enemy");
foreach(var shield in scene.GetAllComponents<ArenaShield>())ray=ray.IgnoreGameObjectHierarchy(shield.GameObject);
var trace=ray.Run();
var flesh=OverdriveEnemy.TraceIntact(scene.Trace.Ray(origin,origin+direction*1700).UsePhysicsWorld(false).UseHitboxes(true).WithTag("overdrive_enemy"));
if(flesh.Hit && (!trace.Hit || flesh.Distance<trace.Distance))trace=flesh;
// A carried plate stack in front of whatever the pellet reached.
var stackOwner=OverdriveEnemy.TraceStack(scene,origin,direction,trace.Hit ? (trace.HitPosition-origin).Length : 1700,out var stackPoint);
if(stackOwner.IsValid())
{
points[i]=stackPoint;normals[i]=-direction;
if(!primaryEnemy.IsValid() || primaryEnemy==stackOwner)
{primaryEnemy=stackOwner;stacks.TryAdd(stackOwner,stackPoint);hitSomething=true;impact=stackPoint;}
continue;
}
if(SausageProjectile.TryIntercept(scene,origin,direction,trace.Hit ? (trace.HitPosition-origin).Length : 1700,shooter,out var intercepted))
{points[i]=intercepted;normals[i]=-direction;hitSomething=true;impact=intercepted;continue;}
points[i]=trace.Hit ? trace.HitPosition : origin+direction*1700;
normals[i]=trace.Normal;
if(!trace.Hit)continue;
impact=trace.HitPosition;
var enemy=OverdriveEnemy.Find(trace.GameObject);
if(enemy.IsValid())
{
// A normal trigger pull has one enemy target, even if the
// spread clips several neighbours. Only earned grenades add area damage.
if(primaryEnemy.IsValid() && primaryEnemy!=enemy)continue;
primaryEnemy=enemy;bodyImpact=impact;
int part=enemy.Zone(impact,trace.Hitbox?.Bone?.Name);
if(part!=0 && (enemy.MissingParts&part)!=0)continue;
float falloff=((impact-origin).Length/1300).Clamp(0,1);
int amount=(int)MathF.Round((dual ? 17-9*falloff : 14-10*falloff)*(shooter.HotShotSeconds>0 ? 1.4f : 1));
if(helper)amount=Math.Max(1,(int)(amount*.38f));
var key=(enemy,part);damage[key]=damage.GetValueOrDefault(key)+amount;hitPoints[key]=impact;hitSomething=true;
}
else
{
var drone=PopRiotTarget.Find(trace.GameObject);
if(drone.IsValid() && (!drone.Crashing || crashingAtStart.Contains(drone)) && (!helper || hitDrones.Add(drone))){drone.Hit(impact,shooter);hitSomething=true;metalHit=true;}
if(!drone.IsValid())surfaceMask|=1<<i;
var dish=RestaurantBreakable.Find(trace.GameObject);
if(dish.IsValid()){var prior=crockery.GetValueOrDefault(dish);crockery[dish]=(prior.Damage+14,impact);surfaceMask &= ~(1<<i);}
var chip=trace.GameObject?.Components.Get<ConcreteChip>();
if(chip.IsValid())concrete[chip]=concrete.GetValueOrDefault(chip)+14;
}
}
// Snapshot the living so the shooter's hit confirmation can tell a kill
// from a hit.
var livingBefore=session.OverdriveEnemies.Where(e=>e.IsValid() && !e.Dead).ToArray();
foreach(var hit in damage)
{
// A head hit represents the whole trigger pull. Other pellets from
// that blast must not kill the first-hit survivor through body damage.
if(hit.Key.Item2!=1 && damage.ContainsKey((hit.Key.Item1,1)))continue;
if(hit.Key.Item2==0)hit.Key.Item1.BlastTorso(hit.Value);
hit.Key.Item1.Damage(hit.Value,hit.Key.Item2,(hitPoints[hit.Key]-origin).Normal,shooter,hitPoints[hit.Key]);
}
// Resolve all pellets against the intact geometry before changing any
// collision, so a blast cannot pass through its own newly opened hole.
foreach(var hit in crockery)hit.Key.Hit(hit.Value.Damage,hit.Value.Point,shooter);
foreach(var stack in stacks){int kind=stack.Key.ShootStack(shooter,stack.Value);if(kind>0)HitConfirm.Callout(shooter.GameObject,OverdriveEnemy.StackCallout(kind),session.RoundId);}
foreach(var hit in concrete)hit.Key.Hit(hit.Value,(hit.Key.WorldPosition-origin).Normal,shooter);
// Intercepted crockery already has its own shatter cue. Only actual
// body hits get the soft impact layer; head bursts own their payoff.
bool bodyHit=damage.Count>0 && !damage.Keys.Any(k=>k.Item1.Dead && (k.Item1.MissingParts&1)!=0);
int kills=livingBefore.Count(e=>e.IsValid() && e.Dead);
Effects(shooter.GameObject,origin,impact,hitSomething,metalHit,bodyHit,bodyImpact,false,session.RoundId,points,normals,surfaceMask,damage.Count>0 || kills>0,kills);
}
[Rpc.Broadcast(NetFlags.HostOnly)]
private static async void Effects(GameObject shooter,Vector3 origin,Vector3 impact,bool hit,bool metalHit,bool bodyHit,Vector3 bodyImpact,bool explosive,int round,Vector3[] points,Vector3[] normals,int surfaceMask,bool enemyHit,int kills)
{
var scene=Game.ActiveScene;await GameTask.MainThread();
if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {} s || !s.IsOverdrive || s.RoundId!=round)return;
// Dual pistols alternate: every other shot comes from the left gun.
var loadout=shooter.IsValid() ? shooter.Components.Get<OverdriveLoadout>() : null;
bool dual=loadout?.DualActive==true;
if(dual)origin=loadout.NextDualMuzzle();
var shot=dual && OverdriveAssets.PistolSound is not null ? Sound.Play(OverdriveAssets.PistolSound,origin) : Sound.Play(SoundPath,origin);
if(shot is not null)shot.Pitch=Game.Random.Float(.95f,1.05f);
if(!dual)FeelAudio.ShotgunLayers(scene,shooter,origin);
ShotFlash.Spawn(scene,origin+(points[0]-origin).Normal*6,new Color(1,.72f,.38f)*3.2f,190,.07f);
if(enemyHit)HitConfirm.Local(scene,shooter,kills>0,impact,kills);
var body=shooter.IsValid() ? shooter.Components.Get<CitizenPlayerPresentation>()?.FootstepRenderer ?? shooter.Components.Get<CitizenOpponentPresentation>()?.FootstepRenderer : null;
if(body.IsValid())body.Set("b_attack",true);
ShotgunShellEjection.Spawn(scene,shooter,origin,(points[0]-origin).Normal);
for(int i=0;i<points.Length;i++)
{
if(i%2==0)OverdriveShotEffects.Tracer(scene,origin,points[i]);
if((surfaceMask&(1<<i))!=0)OverdriveShotEffects.Impact(scene,points[i],normals[i],i);
}
if(hit)
{
if(metalHit || explosive)DroneDamageFlare.Spawn(scene,impact,Color.Orange,explosive,round,explosive ? .5f : .25f);
if(metalHit)DroneAudio.Play("damage",impact);
else if(bodyHit)OverdriveAudio.Play("hit",bodyImpact);
}
}
}
public sealed class OverdriveLoadout : Component
{
// Spaghelli world model coordinates, fitted to Citizen's native hold_R.
public static Vector3 ShotgunOffset => new(3.5f,0,-2);
public static Vector3 SupportOffset => ShotgunOffset+new Vector3(7,1,3.3f);
public static Vector3 MuzzleOffset => ShotgunOffset+new Vector3(20.1969f,0,6.226f);
public Vector3 FittedOffset {get;private set;}=ShotgunOffset;
// Tilt the shotgun about the trigger hand so the stock rides above and
// outside the right forearm instead of passing through it.
public static readonly Rotation ShotgunTilt=Rotation.From(new Angles(0,7,0));
// Hand sits a little ahead of the trigger so the stock ends before the forearm.
public static readonly Vector3 GripPoint=new(-13.1205f,-.6448f,2.252f);
// Shouldered shotgun stance (stock at the shoulder, support hand on the
// fore-end) in Sausage Survival; the crossbow and paintball keep the rifle hold.
public static Sandbox.Citizen.CitizenAnimationHelper.HoldTypes HoldFor(GameObject actor)
=>DualFor(actor) ? Sandbox.Citizen.CitizenAnimationHelper.HoldTypes.Pistol : Sandbox.Citizen.CitizenAnimationHelper.HoldTypes.Rifle;
// Sausage Survival's main weapon is a pair of pistols (the crossbow stays).
public static bool DualFor(GameObject actor)=>actor.IsValid() && MultiplayerSession.Find(actor.Scene)?.IsOverdrive==true && !Bow(actor) && OverdriveAssets.PistolModel is {IsError:false};
// Pistol grip (where the hand bone sits) and muzzle, from the model's bounds.
public static Vector3 PistolGrip{get{var b=OverdriveAssets.PistolModel.Bounds;return new(b.Mins.x+b.Size.x*.14f,b.Center.y,b.Mins.z+b.Size.z*.42f);}}
// Middle of the 1911's grip handle (behind the trigger, lower half).
public static Vector3 DualGrip{get{var b=OverdriveAssets.PistolModel.Bounds;return new(b.Mins.x+b.Size.x*DualPistols.GripBack,b.Center.y,b.Mins.z+b.Size.z*DualPistols.GripUp);}}
public static Vector3 PistolMuzzle{get{var b=OverdriveAssets.PistolModel.Bounds;return new(b.Maxs.x,b.Center.y,b.Maxs.z-b.Size.z*.18f);}}
private static bool Bow(GameObject actor)=>actor.Components.Get<PaintballCombatant>()?.HasCrossbow==true;
public static Vector3 MuzzleFor(GameObject actor)=>MultiplayerSession.Find(actor.Scene)?.IsOverdrive==true ? (actor.Components.Get<OverdriveLoadout>()?.FittedOffset ?? ShotgunOffset)+(Bow(actor) ? new Vector3(9.3f,0,1.8f) : DualFor(actor) ? PistolMuzzle : ShotgunTilt*new Vector3(20.1969f,0,6.226f)) : PaintballMarker.MuzzleOffset;
public static readonly Rotation CrossbowRotation=Rotation.FromYaw(90);
public const float CrossbowScale=.75f;
public static readonly Vector3 CrossbowGrip=new(0,12.4f,-2.4f);
public static readonly Vector3 CrossbowSupport=new(0,3f,.8f);
public static Vector3 SupportFor(GameObject actor)
=>(actor.Components.Get<OverdriveLoadout>()?.FittedOffset ?? ShotgunOffset)
+(Bow(actor) ? CrossbowRotation*(CrossbowSupport*CrossbowScale) : ShotgunTilt*new Vector3(5,1,3.3f));
public float SwapSeconds {get;private set;}
private int observedStage=-1;
private int shownMode=-1;
private GameObject visual,leftVisual;
private bool nextLeft;
private float kickRight,kickLeft;
public bool DualActive=>shownMode==2 && visual.IsValid() && leftVisual.IsValid() && visual.Enabled;
public Vector3 LeftMuzzle=>leftVisual.IsValid() ? leftVisual.WorldTransform.PointToWorld(PistolMuzzle) : WorldPosition;
public Vector3 RightMuzzle=>visual.IsValid() ? visual.WorldTransform.PointToWorld(PistolMuzzle) : WorldPosition;
/// <summary>Alternates the firing gun and kicks it; returns its muzzle.</summary>
public Vector3 NextDualMuzzle()
{
nextLeft=!nextLeft;
if(nextLeft && leftVisual.IsValid() && leftVisual.Enabled){kickLeft=1;return LeftMuzzle;}
kickRight=1;return RightMuzzle;
}
public GameObject Visual=>visual;
public GameObject LeftVisual=>leftVisual;
public bool ShowingShotgun=>visual.IsValid() && visual.Enabled && shownMode==0;
private ModelRenderer original;
protected override void OnUpdate()
{
bool active=MultiplayerSession.Find(Scene)?.IsOverdrive==true;
var actor=Components.Get<PaintballCombatant>();bool bow=actor?.HasCrossbow==true;
int stage=actor?.EquippedWeapon ?? 0;
SwapSeconds=MathF.Max(0,SwapSeconds-Time.Delta);
if(active && stage!=observedStage)
{
if(observedStage>=0){SwapSeconds=1.5f;KitchenCombatAudio.Play("swap",WorldPosition+Vector3.Up*50);}
observedStage=stage;
}
if(active){_=OverdriveAssets.Load();_=OverdriveAssets.LoadPistols();}
var anchor=Components.Get<PaintballMarker>()?.PresentationAnchor ?? Components.Get<ArenaOpponent>()?.PresentationAnchor;
if(active && anchor.IsValid() && !visual.IsValid() && OverdriveAssets.Ready)
{
original=anchor.Components.Get<ModelRenderer>();
visual=new GameObject(anchor,true,"Spaghelli shotgun"){NetworkMode=NetworkMode.Never,LocalPosition=ShotgunOffset};
visual.Components.Create<ModelRenderer>().Model=Model.Load(OverdriveAssets.ShotgunPath);
}
bool dual=active && DualFor(GameObject);
int mode=bow ? 1 : dual ? 2 : 0;
var visualRenderer=visual.IsValid() ? visual.Components.Get<ModelRenderer>() : null;
if(visual.IsValid() && (mode!=shownMode || !visualRenderer.IsValid()))
{
// A presentation rebuild/hotload can retain the anchor while its
// renderer is gone. Restore it even if the selected weapon is unchanged.
if(!visualRenderer.IsValid())visualRenderer=visual.Components.Create<ModelRenderer>();
shownMode=mode;visual.Name=bow ? "Rust crossbow" : dual ? "Right 1911" : "Spaghelli shotgun";
visualRenderer.Model=bow ? Model.Load(OverdriveAssets.CrossbowPath) : dual ? OverdriveAssets.PistolModel : Model.Load(OverdriveAssets.ShotgunPath);
visual.LocalRotation=bow ? CrossbowRotation : dual ? Rotation.Identity : ShotgunTilt;
visual.LocalScale=new Vector3(bow ? CrossbowScale : 1);
if(dual && !leftVisual.IsValid())
{
// Parented to the character so close-camera hiding covers it too.
leftVisual=new GameObject(GameObject,true,"Left 1911"){NetworkMode=NetworkMode.Never};
leftVisual.Components.Create<ModelRenderer>().Model=OverdriveAssets.PistolModel;
}
if(leftVisual.IsValid())leftVisual.Enabled=dual;
}
var melee=Components.Get<CloseCombat>();
bool visible=active && actor?.Eliminated!=true && melee?.Incapacitated!=true;
bool shield=Scene.GetAllComponents<ArenaShield>().Any(x=>x.Owner==actor && !x.Stowed);
if(leftVisual.IsValid())leftVisual.Enabled=visible && dual && !shield;
if(original.IsValid())original.Enabled=!active;
if(visual.IsValid())visual.Enabled=visible;
kickRight=MathF.Max(0,kickRight-Time.Delta*7);kickLeft=MathF.Max(0,kickLeft-Time.Delta*7);
}
protected override void OnPreRender()
{
// Fit both guns to the same evaluated animation frame. Updating only
// the right gun through its bone parent made the left one lag behind.
bool active=MultiplayerSession.Find(Scene)?.IsOverdrive==true;
bool bow=Bow(GameObject),dual=active && DualFor(GameObject);
var anchor=Components.Get<PaintballMarker>()?.PresentationAnchor ?? Components.Get<ArenaOpponent>()?.PresentationAnchor;
var body=Components.Get<CitizenPlayerPresentation>()?.FootstepRenderer ?? Components.Get<CitizenOpponentPresentation>()?.FootstepRenderer;
if(active && visual.IsValid() && anchor.IsValid() && body.IsValid() && body.TryGetBoneTransform("hand_R",out var hand))
{
// Keep the same trigger contact when the native hold changes for a shield.
FittedOffset=anchor.WorldTransform.PointToLocal(hand.Position)-(bow ? CrossbowRotation*(CrossbowGrip*CrossbowScale) : dual ? PistolGrip : ShotgunTilt*GripPoint);
visual.LocalPosition=FittedOffset;
if(dual)
{
// Each pistol is placed in world space: level, pointing along the
// arms, with the middle of its grip handle inside the opening of
// the closed fist. Short muzzle flip on its shots.
var aim=body.TryGetBoneTransform("hold_R",out var holdR) ? holdR.Rotation : anchor.WorldRotation;
if(DualPistols.Palm(body,"R",out var palmR))
{
var rot=aim*Rotation.FromPitch(-8*kickRight*kickRight);
visual.WorldRotation=rot;visual.WorldPosition=palmR-rot*DualGrip;
FittedOffset=anchor.WorldTransform.PointToLocal(visual.WorldPosition);
}
else{FittedOffset=-CitizenMarkerGrip.MarkerOffset;visual.LocalPosition=FittedOffset;visual.LocalRotation=Rotation.Identity;}
if(!leftVisual.IsValid())
{
leftVisual=new GameObject(GameObject,true,"Left 1911"){NetworkMode=NetworkMode.Never};
leftVisual.Components.Create<ModelRenderer>().Model=OverdriveAssets.PistolModel;
}
if(leftVisual.Parent!=GameObject)leftVisual.Parent=GameObject;
if(DualPistols.Palm(body,"L",out var palmL))
{
var leftAim=body.TryGetBoneTransform("hold_L",out var holdL) ? holdL.Rotation : aim;
var rot=leftAim*Rotation.FromPitch(-8*kickLeft*kickLeft);
leftVisual.WorldRotation=rot;leftVisual.WorldPosition=palmL-rot*DualGrip;
}
}
else if(!bow)visual.LocalRotation=ShotgunTilt;
}
}
protected override void OnDestroy(){if(visual.IsValid())visual.Destroy();if(leftVisual.IsValid())leftVisual.Destroy();if(original.IsValid())original.Enabled=true;}
}
/// <summary>Shouldered shotgun stance: the stock sits in the right shoulder
/// pocket, pointing where the character aims. The right hand is placed on the
/// grip by IK (the gun rides on the hand), the left hand already reaches the
/// fore-end, and the arms bend naturally instead of cradling the gun low.</summary>
public static class ShotgunShoulder
{
// Stock position relative to the right shoulder joint (engine units).
// Forward keeps the right hand out in front so the arm stays visible.
public static float ShoulderForward=4.5f,ShoulderInward=1.5f,ShoulderDrop=1.5f;
public static bool Enabled=false;
public static bool Apply(GameObject actor,SkinnedModelRenderer body,Sandbox.Citizen.CitizenAnimationHelper animation,GameObject target,Rotation aim)
{
var loadout=actor.Components.Get<OverdriveLoadout>();
// Disabled: Citizen's arm IK folds the realistic arms badly in this pose.
if(!Enabled || loadout is null || !loadout.ShowingShotgun || !target.IsValid())return false;
if(animation.IkRightHand.IsValid() && animation.IkRightHand!=target)return false;
var model=loadout.Visual.Components.Get<ModelRenderer>()?.Model;
if(model is null || !body.TryGetBoneTransform("hand_R",out var hand) || !body.TryGetBoneTransform("arm_upper_R",out var shoulder))
{if(animation.IkRightHand==target)animation.IkRightHand=null;return false;}
var gun=loadout.Visual.WorldTransform;var b=model.Bounds;
// The gun is rigid on the hand, so its pose relative to the hand is constant.
var gunInHand=hand.Rotation.Inverse*gun.Rotation;
var butt=gun.PointToWorld(new Vector3(b.Mins.x+1.5f,b.Center.y,b.Center.z));
var buttInHand=hand.Rotation.Inverse*(butt-hand.Position);
var handRotation=aim*gunInHand.Inverse;
// Tucked toward the chest so the shorter support arm can reach the fore-end
// with a soft elbow instead of locking straight.
var pocket=shoulder.Position+aim.Forward*ShoulderForward+aim.Left*ShoulderInward-Vector3.Up*ShoulderDrop;
target.WorldRotation=handRotation;
target.WorldPosition=pocket-handRotation*buttInHand;
animation.IkRightHand=target;
return true;
}
}
/// <summary>Akimbo pose: the left hand and pistol sit beside the right ones,
/// one shoulder-width to the left, both guns pointing straight ahead. The
/// sideways axis comes from the hips (the torso twists in the pistol pose).</summary>
public static class DualPistols
{
public readonly record struct Plane(Vector3 Center,Vector3 Normal,float Width);
public static Plane Frame(SkinnedModelRenderer body)
{
var n=body.WorldRotation.Left;var c=body.WorldPosition;float width=14;
if(body.TryGetBoneTransform("leg_upper_L",out var hl) && body.TryGetBoneTransform("leg_upper_R",out var hr))
{var d=(hl.Position-hr.Position).WithZ(0);if(d.Length>1){n=d.Normal;c=(hl.Position+hr.Position)/2;}}
if(body.TryGetBoneTransform("arm_upper_L",out var sl) && body.TryGetBoneTransform("arm_upper_R",out var sr))
width=((sl.Position-sr.Position).Length*.9f).Clamp(9,20);
return new(c,n,width);
}
public static float GripBack=.25f,GripUp=.37f,PalmAlong=.75f,PalmPull=.35f;
static bool palmLogged;
/// <summary>Centre of the closed fist: most of the way from the wrist to the
/// middle knuckle, where a gun grip passes through the curled fingers.</summary>
public static bool Palm(SkinnedModelRenderer body,string side,out Vector3 palm)
{
palm=default;
if(!body.TryGetBoneTransform("hand_"+side,out var wrist))return false;
// Centre of the hole the curled fingers make: the knuckle row, the
// middle and tip joints of the curled fingers, pulled toward the palm.
var pts=new List<Vector3>();
foreach(var f in new[]{"index","middle","ring"})
for(int j=0;j<3;j++)
{
var name=$"finger_{f}_{j}_{side}";
if(body.TryGetBoneTransform(name,out var bone))pts.Add(bone.Position);
}
if(pts.Count<3)
{
if(!palmLogged){palmLogged=true;Log.Warning("DUAL_PALM finger bones missing");}
return false;
}
var curl=pts.Aggregate(Vector3.Zero,(x,y)=>x+y)/pts.Count;
var knuckles=body.TryGetBoneTransform("finger_middle_0_"+side,out var k0) ? k0.Position : curl;
palm=Vector3.Lerp(curl,Vector3.Lerp(wrist.Position,knuckles,PalmAlong),PalmPull);
if(!palmLogged){palmLogged=true;Log.Info($"DUAL_PALM bones={pts.Count} wristToOpening={(palm-wrist.Position).Length:0.0}");}
return true;
}
// Two-handed akimbo: the pair of hands is centred on the line from the
// breastbone along the aim, hands a little narrower than the shoulders,
// elbows soft (never locked straight), both guns parallel on the aim.
public static float Reach=.86f,Droop=4,Spacing=.8f,MaxReach=.95f;
static readonly Dictionary<SkinnedModelRenderer,Rotation> aims=new();
/// <summary>Where this character is aiming (set by the presentation each frame).</summary>
public static Rotation AimFor(SkinnedModelRenderer body)=>body.IsValid() && aims.TryGetValue(body,out var r) ? r : Aim(body);
public static void BothHands(SkinnedModelRenderer body,Sandbox.Citizen.CitizenAnimationHelper animation,GameObject right,GameObject left,float aimYaw,float aimPitch,float lift=0)
{
if(!right.IsValid() || !left.IsValid())return;
if(!body.TryGetBoneTransform("hand_R",out var handR) || !body.TryGetBoneTransform("hand_L",out var handL)
|| !body.TryGetBoneTransform("hold_R",out var holdR) || !body.TryGetBoneTransform("hold_L",out var holdL)
|| !body.TryGetBoneTransform("arm_upper_R",out var shR) || !body.TryGetBoneTransform("arm_upper_L",out var shL)
|| !body.TryGetBoneTransform("arm_lower_R",out var elR) || !body.TryGetBoneTransform("arm_lower_L",out var elL))return;
if(aims.Count>64)aims.Clear();
var aim=Rotation.FromYaw(aimYaw)*Rotation.FromPitch(aimPitch.Clamp(-35,35));
aims[body]=aim;
var dir=aim*Rotation.FromPitch(Droop);
float armR=(elR.Position-shR.Position).Length+(handR.Position-elR.Position).Length;
float armL=(elL.Position-shL.Position).Length+(handL.Position-elL.Position).Length;
var chest=(shL.Position+shR.Position)/2;
float half=(shL.Position-shR.Position).Length*.5f*Spacing;
var center=chest+dir.Forward*MathF.Min(armR,armL)*Reach+Vector3.Up*lift;
var flatLeft=Rotation.FromYaw(aimYaw).Left;
var rightPos=Reachable(shR.Position,center-flatLeft*half,armR);
var leftPos=Reachable(shL.Position,center+flatLeft*half,armL);
// Wrists: turn each hand so its grip frame (hold bone, which the pistol
// model is authored against) matches the gun: palm against the side of
// the grip, fingers wrapped around the front, thumb up, knuckles on aim.
var gun=aim;
var relR=handR.Rotation.Inverse*holdR.Rotation;
var relL=handL.Rotation.Inverse*holdL.Rotation;
right.WorldPosition=rightPos;right.WorldRotation=gun*relR.Inverse;
left.WorldPosition=leftPos;left.WorldRotation=gun*relL.Inverse;
animation.IkRightHand=right;animation.IkLeftHand=left;
}
// Keeps a wrist target inside the arm's reach so the elbow never hyperextends.
static Vector3 Reachable(Vector3 shoulder,Vector3 target,float arm)
{
var d=target-shoulder;float max=arm*MaxReach;
return d.Length>max ? shoulder+d.Normal*max : target;
}
/// <summary>Level aim along the arms (shoulders to hands, averaged).</summary>
public static Rotation Aim(SkinnedModelRenderer body)
{
var dir=Vector3.Zero;
foreach(var side in new[]{"R","L"})
if(body.TryGetBoneTransform("arm_upper_"+side,out var sh) && body.TryGetBoneTransform("hand_"+side,out var h))dir+=(h.Position-sh.Position).WithZ(0).Normal;
if(dir.Length<.1f)dir=body.WorldRotation.Forward;
return Rotation.LookAt(dir.WithZ(0).Normal,Vector3.Up);
}
public static Vector3 MirrorDirection(Plane p,Vector3 dir)=>dir-2*Vector3.Dot(dir,p.Normal)*p.Normal;
public static Rotation MirrorRotation(Plane p,Rotation r)=>Rotation.LookAt(MirrorDirection(p,r.Forward),MirrorDirection(p,r.Up));
// Correction for the left hand bone's own axes, if the wrist reads twisted.
public static Angles LeftHandFix=new(0,0,180);
public static Transform LeftHandTarget(Plane p,Transform rightHand)
=>new Transform(rightHand.Position+p.Normal*p.Width,MirrorRotation(p,rightHand.Rotation)*Rotation.From(LeftHandFix));
public static void LeftHand(SkinnedModelRenderer body,Sandbox.Citizen.CitizenAnimationHelper animation,GameObject target)
{
if(!target.IsValid() || !body.TryGetBoneTransform("hand_R",out var hand))return;
var p=Frame(body);
if(body.TryGetBoneTransform("hold_R",out var holdR) && body.TryGetBoneTransform("hand_L",out var handL) && body.TryGetBoneTransform("hold_L",out var holdL))
{
// Want hold_L parallel to hold_R (both guns pointing the same way),
// one shoulder-width to the left. Solve back to the wrist target.
var relRot=handL.Rotation.Inverse*holdL.Rotation;
var relPos=handL.Rotation.Inverse*(holdL.Position-handL.Position);
var wantRot=holdR.Rotation;
var wantPos=holdR.Position+p.Normal*p.Width;
var handRot=wantRot*relRot.Inverse;
target.WorldRotation=handRot;
target.WorldPosition=wantPos-handRot*relPos;
}
else
{
var t=LeftHandTarget(p,hand);
target.WorldPosition=t.Position;target.WorldRotation=t.Rotation;
}
animation.IkLeftHand=target;
}
}