SausageProjectile.cs
using Sandbox;
using System;
namespace PaintballBaddies;
/// <summary>Host-owned kitchen throws with readable flight and shootable interception.</summary>
public sealed class SausageProjectile : Component
{
[Sync(SyncFlags.FromHost)] public bool Plate {get;set;}
[Sync(SyncFlags.FromHost)] public bool Egg {get;set;}
[Sync(SyncFlags.FromHost)] public Vector3 Velocity {get;set;}
[Sync(SyncFlags.FromHost)] public GameObject Thrower {get;set;}
[Sync(SyncFlags.FromHost)] public int Round {get;set;}
[Sync(SyncFlags.FromHost)] public Vector3 LaunchPosition {get;set;}
[Sync(SyncFlags.FromHost)] public float LaunchDistance {get;set;}
[Sync(SyncFlags.FromHost)] public GameObject IntendedTarget {get;set;}
public float JourneyFraction=>LaunchDistance>1 ? (WorldPosition-LaunchPosition).Length/LaunchDistance : 0;
public bool Resolved {get;private set;}
public static int Interceptions {get;private set;}
public static int PlayerHits {get;private set;}
public static int ShieldBlocks {get;private set;}
public static int PlateThrows {get;private set;}
public static int KnifeThrows {get;private set;}
public static int EggThrows {get;private set;}
public static string LastCollision {get;private set;}="";
private GameObject visual;
private float age;
public static bool Launch(OverdriveEnemy enemy,Vector3 aim,bool plate,GameObject intendedTarget=null,bool egg=false)
{
if(!MultiplayerSession.Authority || enemy.Dead || (enemy.MissingParts&6)==6 || enemy.Scene.GetAllComponents<SausageProjectile>().Count()>11)return false;
var origin=enemy.Components.Get<OverdriveRobotRig>()?.ThrowOrigin ?? enemy.AimPoint+enemy.WorldRotation.Forward*24;
// A hand extending through nearby geometry must not throw through it.
var clearance=enemy.Scene.Trace.Sphere(egg ? 2.3f : plate ? 3.6f : 1.4f,enemy.AimPoint,origin).WithSurfaceMeshes()
.IgnoreGameObjectHierarchy(enemy.GameObject).WithoutTags("paintball_debris","overdrive_enemy").Run();
if(clearance.Hit || clearance.StartedSolid)return false;
var distance=(aim-origin).Length;
float speed=MathF.Min(plate ? 610 : 760,distance/(plate ? .95f : .65f)).Clamp(plate ? 120 : 220,760);
var go=new GameObject(false,egg ? "Flying egg" : plate ? "Flying ceramic plate" : "Flying kitchen knife"){WorldPosition=origin};
go.Tags.Add("paintball_debris");
var shot=go.Components.Create<SausageProjectile>();shot.Plate=plate && !egg;shot.Egg=egg;shot.Thrower=enemy.GameObject;shot.Round=enemy.Round;
shot.LaunchPosition=origin;shot.LaunchDistance=distance;shot.IntendedTarget=intendedTarget;
shot.Velocity=(aim-origin).Normal*(egg ? MathF.Min(500,distance/.85f).Clamp(120,500) : speed);go.Enabled=true;
if(MultiplayerSession.Online)
{
go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);
// A delayed character-style interpolation buffer leaves a fast,
// interceptable plate visually behind its authoritative hit point.
go.Network.Interpolation=false;go.NetworkSpawn();
}
if(egg)EggThrows++;else if(plate)PlateThrows++;else KnifeThrows++;
return true;
}
internal static GameObject CreateVisual(GameObject parent,bool plate,bool egg=false)
{
// A real egg shape (blunt and pointed ends, speckled shell) when the
// project model has compiled; otherwise the old stretched sphere.
var realEgg=egg ? Model.Load(OverdriveAssets.RealEggPath) : null;
bool shaped=realEgg is not null && !realEgg.IsError;
var model=egg ? (shaped ? realEgg : Model.Load(OverdriveAssets.EggPath)) : Model.Load(plate ? OverdriveAssets.PlatePath : OverdriveAssets.KnifePath);
var pivot=new GameObject(parent,true,"Kitchen prop spin"){NetworkMode=NetworkMode.Never};
var mesh=new GameObject(pivot,true,"Kitchen prop mesh"){NetworkMode=NetworkMode.Never,LocalPosition=-model.Bounds.Center,LocalScale=Vector3.One};
var renderer=mesh.Components.Create<ModelRenderer>();renderer.Model=model;
if(plate){pivot.LocalScale=new Vector3(2.7f);renderer.MaterialOverride=Material.Load("materials/dev/primary_white.vmat");renderer.Tint=new Color(.92f,.9f,.84f);}
if(egg)
{
pivot.LocalScale=new Vector3(4.4f/model.Bounds.Size.x,4.4f/model.Bounds.Size.y,5.8f/model.Bounds.Size.z);
if(shaped)renderer.Tint=new Color(.99f,.94f,.86f);
else{renderer.MaterialOverride=Material.Load("materials/dev/primary_white.vmat");renderer.Tint=new Color(.96f,.91f,.78f);}
}
return pivot;
}
protected override void OnStart(){_=OverdriveAssets.Load();}
protected override void OnUpdate()
{
age+=Time.Delta;
if(!visual.IsValid() && OverdriveAssets.Ready)visual=CreateVisual(GameObject,Plate,Egg);
if(visual.IsValid())visual.WorldRotation=Rotation.LookAt(Velocity.Normal)*(Plate ? Rotation.From(new Angles(18,age*900,0)) : Rotation.FromPitch(age*1150));
if(!MultiplayerSession.Authority)return;
var session=MultiplayerSession.Find(Scene);
if(session?.IsOverdrive!=true || !session.InRound || session.RoundId!=Round || age>4){GameObject.Destroy();return;}
var end=WorldPosition+Velocity*Time.Delta;
var hit=Scene.Trace.Sphere(Plate ? 3.6f : Egg ? 2.3f : 1.4f,WorldPosition,end).WithSurfaceMeshes()
.IgnoreGameObjectHierarchy(GameObject).IgnoreGameObjectHierarchy(Thrower).WithoutTags("paintball_debris","overdrive_enemy").Run();
if(!hit.Hit){WorldPosition=end;return;}
LastCollision=hit.GameObject?.Name ?? "world";
if(ArenaShield.TryAbsorbKitchen(hit,WorldPosition-Velocity.Normal*30))ShieldBlocks++;
else if(PaintballCombatant.Find(hit.GameObject) is {} actor)
{
actor.ReceiveOverdriveDamage(Egg ? 14 : Plate ? 18 : 24,WorldPosition);PlayerHits++;
if(Egg)(actor.Components.Get<CharacterPaint>() ?? actor.Components.Create<CharacterPaint>()).Add(hit,YolkBurst.Yellow);
}
Resolve(hit.HitPosition);
}
public static bool TryIntercept(Scene scene,Vector3 origin,Vector3 direction,float distance,PaintballCombatant shooter,out Vector3 point)
{
point=default;
if(!MultiplayerSession.Authority)return false;
var candidate=scene.GetAllComponents<SausageProjectile>().Where(p=>!p.Resolved)
.Select(p=>new {Shot=p,Along=Vector3.Dot(p.WorldPosition-origin,direction)})
.Where(p=>p.Along>0 && p.Along<distance && (p.Shot.WorldPosition-(origin+direction*p.Along)).Length<(p.Shot.Plate ? 12 : 6))
.OrderBy(p=>p.Along).FirstOrDefault();
if(candidate is null)return false;
point=candidate.Shot.WorldPosition;candidate.Shot.Resolve(point);Interceptions++;
MultiplayerSession.Find(scene)?.OverdriveKill(shooter,10,point);
return true;
}
private void Resolve(Vector3 point)
{
if(Resolved)return;Resolved=true;
if(Egg)SausageEggSlick.Spawn(Scene,point,Round);
BreakEffect(point,Plate,Egg,Round);GameObject.Destroy();
}
[Rpc.Broadcast(NetFlags.HostOnly)]
private static async void BreakEffect(Vector3 point,bool plate,bool egg,int round)
{
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;
if(egg){YolkBurst.Spawn(point);KitchenCombatAudio.Play("egg",point);return;}
if(plate)SausageCeramicBurst.Spawn(point);
else DroneDamageFlare.Spawn(scene,point,Color.White,false,round,.2f);
SausageKitchenAudio.Play(plate,point);
}
}
public static class SausageKitchenAudio
{
public static int CeramicBreaks {get;private set;}
public static int MetalImpacts {get;private set;}
private static SoundEvent Cue(string[] paths,float volume,float pitch)=>new(){Sounds=paths.Select(SoundFile.Load).ToList(),Volume=volume,Pitch=pitch,Distance=950,DistanceAttenuation=true,OcclusionEnabled=false,AirAbsorption=false};
private static readonly SoundEvent metal=Cue(["sounds/physics/phys-impact-metal-spring-001.vsnd","sounds/physics/phys-impact-metal-spring-003.vsnd"],.5f,1.3f);
public static void Play(bool plate,Vector3 point)
{if(plate){CeramicBreaks++;SausageAudio.Play("plate",point);}else{MetalImpacts++;Sound.Play(metal,point);}}
}
public sealed class SausageCeramicBurst : Component
{
private float age;
public static void Spawn(Vector3 point)
{
var go=new GameObject(true,"Ceramic plate shards"){NetworkMode=NetworkMode.Never,WorldPosition=point};go.Tags.Add("paintball_debris");go.Components.Create<SausageCeramicBurst>();
}
protected override void OnStart()
{
foreach(var old in Scene.GetAllComponents<SausageCeramicBurst>().OrderByDescending(x=>x.age).SkipLast(5).ToArray())old.GameObject.Destroy();
var layout=Json.Deserialize<ShieldBreakEffect.Layout>(FileSystem.Mounted.ReadAllText("models/gear/clear_shield/fracture/layout.json"));
var p=Components.Create<ParticleEffect>();p.MaxParticles=12;p.Lifetime=1.4f;p.LocalSpace=0;
p.ApplyShape=p.ApplyRotation=p.ApplyColor=p.ApplyAlpha=false;p.Force=true;p.ForceDirection=Vector3.Down*500;p.ForceScale=1;p.ForceSpace=ParticleEffect.SimulationSpace.World;
p.Collision=true;p.CollisionRadius=.5f;p.Bounce=.25f;p.PushStrength=0;
var render=Components.Create<ParticleModelRenderer>();render.Choices=layout.Pieces.Take(6).Select(x=>new ParticleModelRenderer.ModelEntry{Model=Model.Load(x.Model)}).ToList();
render.MaterialOverride=Material.Load("materials/dev/primary_white.vmat");render.CastShadows=false;render.Scale=1;render.RotateWithGameObject=false;
p.OnStep=(part,dt)=>{part.Angles+=new Angles(240,390,150)*dt;part.Alpha=((1.4f-part.Age)/.35f).Clamp(0,1);};
for(int i=0;i<12;i++){var part=p.Emit(WorldPosition,0);part.Size=new Vector3(.12f);part.Color=new Color(.92f,.9f,.84f);part.Velocity=Rotation.FromYaw(i*137).Forward*130+Vector3.Up*(70+i*5);}
}
protected override void OnUpdate(){age+=Time.Delta;if(age>1.6f)GameObject.Destroy();}
}