SausageCrossbow.cs
using Sandbox;
using System;
namespace PaintballBaddies;
/// <summary>Host-swept bolts. No client hit claims and no friendly-fire damage.</summary>
public sealed class SausageBolt : Component
{
[Sync(SyncFlags.FromHost)] public Vector3 Velocity {get;set;}
[Sync(SyncFlags.FromHost)] public GameObject Shooter {get;set;}
[Sync(SyncFlags.FromHost)] public int Round {get;set;}
public static int Hits {get;private set;}
private float age;
private bool resolved;
private LineRenderer flightTrail;
public static void Fire(PaintballCombatant actor,Vector3 origin,Vector3 aim)
{
if(!MultiplayerSession.Authority)return;
var go=new GameObject(false,"Flying crossbow bolt"){WorldPosition=origin};go.Tags.Add("paintball_debris");
var bolt=go.Components.Create<SausageBolt>();bolt.Shooter=actor.GameObject;bolt.Round=MultiplayerSession.Find(actor.Scene).RoundId;
bolt.Velocity=(aim-origin).Normal*2400;go.WorldRotation=Rotation.LookAt(bolt.Velocity.Normal);go.Enabled=true;
if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.Network.Interpolation=false;go.NetworkSpawn();}
Cue(origin,"bow",bolt.Round,actor.GameObject);
}
internal static GameObject Visual(GameObject parent)
{
// Keep the tip at the physical hit point when enlarging the native bolt.
var go=new GameObject(parent,true,"Crossbow bolt mesh"){NetworkMode=NetworkMode.Never,LocalScale=new Vector3(.60f,.92f,.92f),LocalPosition=new Vector3(-28.4036f*.60f,0,0)};
var renderer=go.Components.Create<ModelRenderer>();renderer.Model=Model.Load(OverdriveAssets.BoltPath);
renderer.MaterialOverride=Material.Load("materials/weapons/readable_bolt.vmat");return go;
}
protected override void OnStart()
{
Visual(GameObject);
// Armed in flight; the red tip goes dark on penetration while the fuse beeps.
PopRiotTarget.Sphere(GameObject,Vector3.Zero,.48f,new Color(1,.025f,.015f),true);
var trail=new GameObject(GameObject,true,"Crossbow flight ribbon"){NetworkMode=NetworkMode.Never};
flightTrail=trail.Components.Create<LineRenderer>();flightTrail.UseVectorPoints=true;
flightTrail.Face=SceneLineObject.FaceMode.Camera;flightTrail.Lighting=false;flightTrail.CastShadows=false;
flightTrail.Opaque=false;flightTrail.Additive=false;
flightTrail.Width=new Curve(new Curve.Frame(0,.04f),new Curve.Frame(1,.32f));
flightTrail.Color=Gradient.FromColors(new Color(.8f,.8f,.76f,0),new Color(.86f,.86f,.8f,.28f));
}
protected override void OnUpdate()
{
age+=Time.Delta;
if(flightTrail.IsValid())flightTrail.VectorPoints=[WorldPosition-Velocity.Normal*MathF.Min(30,age*Velocity.Length),WorldPosition];
}
protected override void OnFixedUpdate()
{
if(!MultiplayerSession.Authority || resolved)return;
var session=MultiplayerSession.Find(Scene);
if(session?.IsOverdrive!=true || !session.InRound || Round!=session.RoundId || age>1.3f){GameObject.Destroy();return;}
var actor=Shooter.IsValid() ? Shooter.Components.Get<PaintballCombatant>() : null;
var end=WorldPosition+Velocity*Time.Delta;
var ray=Scene.Trace.Sphere(.5f,WorldPosition,end).WithSurfaceMeshes().WithoutTags("paintball_actor","paintball_debris","overdrive_enemy");
foreach(var shield in Scene.GetAllComponents<ArenaShield>())ray=ray.IgnoreGameObjectHierarchy(shield.GameObject);
var hit=ray.Run();
// Native animated Citizen hitboxes avoid sticking arrows to the outer
// navigation box, which can leave them floating above a moving head.
var flesh=OverdriveEnemy.TraceIntact(Scene.Trace.Sphere(.5f,WorldPosition,end).UsePhysicsWorld(false).UseHitboxes(true).WithTag("overdrive_enemy"));
if(flesh.Hit && (!hit.Hit || flesh.Fraction<hit.Fraction))hit=flesh;
float distance=hit.Hit ? (hit.HitPosition-WorldPosition).Length : (end-WorldPosition).Length;
if(OverdriveEnemy.TraceStack(Scene,WorldPosition,Velocity.Normal,distance,out var stackPoint) is {} carrier && carrier.ShootStack(actor,stackPoint) is int kind && kind>0)
{
if(actor.IsValid())HitConfirm.Callout(actor.GameObject,OverdriveEnemy.StackCallout(kind),Round);
resolved=true;GameObject.Destroy();return;
}
if(SausageProjectile.TryIntercept(Scene,WorldPosition,Velocity.Normal,distance,actor,out _)){resolved=true;GameObject.Destroy();return;}
if(!hit.Hit){WorldPosition=end;return;}
resolved=true;
var enemy=OverdriveEnemy.Find(hit.GameObject);
if(enemy.IsValid() && !enemy.Dead)
{
SausageExplosiveBolt.Attach(enemy,actor,hit.HitPosition,Velocity.Normal,hit.Hitbox?.Bone?.Name);
Hits++;Cue(hit.HitPosition,"bolt",Round,null);
if(actor.IsValid())HitConfirm.Send(actor.GameObject,false,hit.HitPosition,Round);
}
else if(RestaurantBreakable.Find(hit.GameObject) is {} dish)
{
dish.Hit(dish.Kind=="chair" ? 8 : 48,hit.HitPosition,actor);
if(dish.IsValid() && !dish.BrokenState)Surface(hit.HitPosition,hit.Normal,Velocity.Normal,Round,dish.GameObject);
else Cue(hit.HitPosition,"bolt",Round,null);
}
else Surface(hit.HitPosition,hit.Normal,Velocity.Normal,Round,null);
GameObject.Destroy();
}
[Rpc.Broadcast(NetFlags.HostOnly)] internal static async void Cue(Vector3 point,string kind,int round,GameObject shooter)
{
var scene=Game.ActiveScene;await GameTask.MainThread();
if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
KitchenCombatAudio.Play(kind,point);
if(shooter.IsValid())
{
var body=shooter.Components.Get<CitizenPlayerPresentation>()?.FootstepRenderer ?? shooter.Components.Get<CitizenOpponentPresentation>()?.FootstepRenderer;
if(body.IsValid())body.Set("b_attack",true);
}
}
[Rpc.Broadcast(NetFlags.HostOnly)] private static async void Surface(Vector3 point,Vector3 normal,Vector3 direction,int round,GameObject receiver)
{
var scene=Game.ActiveScene;await GameTask.MainThread();
if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
KitchenCombatAudio.Play("bolt",point);
var go=new GameObject(true,"Bolt stuck in cover"){NetworkMode=NetworkMode.Never,WorldPosition=point+direction*1.5f,WorldRotation=Rotation.LookAt(direction)};
if(receiver.IsValid())go.Parent=receiver;
go.Tags.Add("paintball_debris");Visual(go);go.Components.Create<EmbeddedBoltLifetime>();
}
}
public sealed class SausageEmbeddedBolts : Component
{
public sealed class Record
{
public string Bone {get;set;}
public Vector3 Point {get;set;}
public Rotation Rotation {get;set;}
public int Part {get;set;}
}
// Keep history on the authoritative enemy's initial network snapshot;
// this component only presents the replicated records.
public string History=>Components.Get<OverdriveEnemy>()?.EmbeddedBoltState ?? "";
private string observed;
private readonly List<(Record Data,GameObject Visual)> bolts=new();
public int VisibleCount=>bolts.Count(b=>b.Visual.IsValid() && b.Visual.Enabled);
internal void Stick(Vector3 point,Vector3 direction,string hitBone=null)
{
if(!MultiplayerSession.Authority)return;
var enemy=Components.Get<OverdriveEnemy>();var rig=Components.Get<OverdriveRobotRig>();
if(enemy?.Dead!=false || rig?.Body.IsValid()!=true)return;
int part=string.IsNullOrEmpty(hitBone) ? enemy.Zone(point) : OverdriveRobotRig.Part(hitBone);
string[] candidates=part switch {1=>["head"],8=>["leg_upper_L","leg_lower_L"],16=>["leg_upper_R","leg_lower_R"],2=>["arm_upper_L","arm_lower_L"],4=>["arm_upper_R","arm_lower_R"],_=>["spine_0","spine_1","spine_2"]};
string bone=string.IsNullOrEmpty(hitBone) ? candidates.OrderBy(n=>(rig.IntactPose(n).Position-point).Length).First() : hitBone;
var pose=rig.IntactPose(bone);
var list=string.IsNullOrEmpty(History) ? new List<Record>() : Json.Deserialize<List<Record>>(History);
if(list.Count>=4)list.RemoveAt(0);
list.Add(new(){Bone=bone,Point=pose.PointToLocal(point+direction*2),Rotation=pose.Rotation.Inverse*Rotation.LookAt(direction),Part=part});
enemy.EmbeddedBoltState=Json.Serialize(list);
}
protected override void OnPreRender()
{
var rig=Components.Get<OverdriveRobotRig>();var enemy=Components.Get<OverdriveEnemy>();
if(!enemy.IsValid() || rig?.Body.IsValid()!=true)return;
if(observed!=History)
{
foreach(var item in bolts)if(item.Visual.IsValid())item.Visual.Destroy();bolts.Clear();observed=History;
if(!string.IsNullOrEmpty(History))foreach(var record in Json.Deserialize<List<Record>>(History))
{
var pivot=new GameObject(GameObject,true,"Embedded sausage bolt"){NetworkMode=NetworkMode.Never};pivot.Tags.Add("paintball_debris");
SausageBolt.Visual(pivot);bolts.Add((record,pivot));
}
}
if(bolts.Count==0)return;
var corpse=enemy.Dead ? Scene.GetAllComponents<OverdriveCorpse>().FirstOrDefault(c=>c.SourceEnemy==GameObject) : null;
foreach(var item in bolts)
{
if(!item.Visual.IsValid())continue;
item.Visual.Enabled=(enemy.MissingParts&item.Data.Part)==0 && (!enemy.Dead || corpse.IsValid());
var pose=rig.IntactPose(item.Data.Bone);
if(corpse?.VisibleBody.IsValid()==true && corpse.VisibleBody.TryGetBoneTransform(item.Data.Bone,out var fallen))pose=fallen;
item.Visual.WorldPosition=pose.PointToWorld(item.Data.Point);item.Visual.WorldRotation=pose.Rotation*item.Data.Rotation;
}
}
}
public sealed class EmbeddedBoltLifetime : Component
{
private float age;
protected override void OnStart(){foreach(var old in Scene.GetAllComponents<EmbeddedBoltLifetime>().OrderByDescending(b=>b.age).SkipLast(23).ToArray())old.GameObject.Destroy();}
protected override void OnUpdate(){age+=Time.Delta;if(age>12)GameObject.Destroy();}
}