OverdriveEnemy.cs
using Sandbox;
using System;
namespace PaintballBaddies;
public sealed partial class OverdriveEnemy : Component
{
[Sync(SyncFlags.FromHost)] public int Health {get;private set;}=110;
[Sync(SyncFlags.FromHost)] public int MissingParts {get;private set;}
[Sync(SyncFlags.FromHost)] public int HeadHits {get;private set;}
public bool HeadWounded=>HeadHits==1 && !Dead;
// AttackStyle is already host-synchronized, so the unarmed role agrees on every peer.
public bool IsRusher=>AttackStyle==0;
public int VisibleDamageParts=>MissingParts | (HeadHits>0 ? 1 : 0);
public float MovementSpeed
{
get
{
float speed=(MissingParts&24)!=0 ? 82 : Hefty ? (HollowTorso ? 115 : 165) : 205;
if(IsRusher && (MissingParts&24)==0)speed*=1.35f;
if(Enraged && (MissingParts&24)==0)speed*=1.4f;
return speed*(HeadWounded ? .6f : 1);
}
}
[Sync(SyncFlags.FromHost)] public int HeadBreakVariant {get;private set;}=2;
[Sync(SyncFlags.FromHost)] public Vector3 HeadImpactLocal {get;private set;}
[Sync(SyncFlags.FromHost)] public float SlipRemaining {get;private set;}
[Sync(SyncFlags.FromHost)] public int SlipSerial {get;private set;}
public const float SlipDuration=2.65f;
private float slipImmunity;
public static int Slips {get;private set;}
[Sync(SyncFlags.FromHost)] public int Round {get;set;}
[Sync(SyncFlags.FromHost)] public bool Dead {get;private set;}
[Sync(SyncFlags.FromHost)] public bool Hefty {get;private set;}
[Sync(SyncFlags.FromHost)] public bool HollowTorso {get;private set;}
[Sync(SyncFlags.FromHost)] public string EmbeddedBoltState {get;internal set;}="";
private int torsoBlast;
private bool shownHollow;
internal void SetHefty(bool value){if(!MultiplayerSession.Authority)return;Hefty=value;Health=value ? 240 : 110;}
internal void BlastTorso(int amount)
{if(MultiplayerSession.Authority && Hefty && !Dead && amount>0){torsoBlast+=amount;if(torsoBlast>=40)HollowTorso=true;}}
[Sync(SyncFlags.FromHost)] public Vector3 LastImpulse {get;private set;}
[Sync(SyncFlags.FromHost)] public int ReactionSerial {get;private set;}
[Sync(SyncFlags.FromHost)] public float Windup {get;private set;}
[Sync(SyncFlags.FromHost)] public int AttackStyle {get;set;}
[Sync(SyncFlags.FromHost)] public bool RangedWindup {get;private set;}
[Sync(SyncFlags.FromHost)] public bool ThrowPlate {get;private set;}
[Sync(SyncFlags.FromHost)] public bool ThrowEgg {get;private set;}
[Sync(SyncFlags.FromHost)] public float ThrowRecovery {get;private set;}
[Sync(SyncFlags.FromHost)] public float CollapseRemaining {get;private set;}
public bool HeadlessStagger=>Dead && CollapseRemaining>0;
public NavMeshAgent Agent {get;private set;}
public Vector3 AimPoint=>WorldPosition+Vector3.Up*35;
private readonly Dictionary<int,int> limbDamage=new();
private PaintballCombatant target;
private float rethink,attackClock=1,stagger,deadTime;
private int shownParts,shownHeadHits;
private Vector3 stumbleForward;
private Vector3 throwAim;
private int shieldThrowCount;
private SoundHandle sputter;
// Host-only: an entry door to pass through before chasing, so the horde
// splits between the dining room doors instead of queuing at one.
internal Vector3? RouteVia {get;set;}
// Plate throwers carry a stack in the off hand. Shooting it out of the hand
// smashes it and sends the sausage charging, enraged, at whoever did it.
[Sync(SyncFlags.FromHost)] public int PlatesCarried {get;private set;}
// Egg throwers carry a carton; shooting it splatters them and they slip.
[Sync(SyncFlags.FromHost)] public int EggsCarried {get;private set;}
internal void SetEggCarton(int count){if(MultiplayerSession.Authority)EggsCarried=Math.Clamp(count,0,6);}
public bool Carrying=>PlatesCarried>0 || EggsCarried>0;
public const float HeadbuttTime=.6f;
// Reach into the load, take one, cock, throw.
public const float ThrowWindup=1f,ThrowGrabAt=.3f;
[Sync(SyncFlags.FromHost)] public bool Enraged {get;private set;}
[Sync(SyncFlags.FromHost)] public int StackBreakSerial {get;private set;}
[Sync(SyncFlags.FromHost)] public Vector3 StackBreakPoint {get;private set;}
private PaintballCombatant rageTarget;
public static int StacksShot {get;private set;}
internal void SetPlateStack(int count){if(MultiplayerSession.Authority)PlatesCarried=Math.Clamp(count,0,10);}
// Host-side estimate of where the stack is; the rig refines it every frame.
internal Vector3 StackCenter=>Components.Get<OverdriveRobotRig>()?.StackCenter ?? WorldPosition+WorldRotation.Left*15+Vector3.Up*42;
internal static string StackCallout(int kind)=>kind==2 ? "EGGS SMASHED! WATCH HIM SLIP" : "PLATES SMASHED! HE'S ANGRY";
/// <summary>Returns 0 when nothing was hit, 1 for a plate stack, 2 for an egg carton.</summary>
internal int ShootStack(PaintballCombatant shooter,Vector3 point)
{
var s=MultiplayerSession.Find(Scene);
if(!MultiplayerSession.Authority || Dead || !Carrying || s?.IsOverdrive!=true || !s.InRound || Round!=s.RoundId)return 0;
if(EggsCarried>0)
{
// A carton full of eggs bursts all over the sausage's own feet.
EggsCarried=0;StackBreakPoint=point;StackBreakSerial++;StacksShot++;
SausageEggSlick.Spawn(Scene,WorldPosition+Vector3.Up*20,Round);
Windup=0;RangedWindup=false;ThrowRecovery=0;TrySlip(true);
s.OverdriveKill(shooter,25,point);
return 2;
}
PlatesCarried=0;StackBreakPoint=point;StackBreakSerial++;StacksShot++;
if(shooter.IsValid()){Enraged=true;rageTarget=shooter;}
// A beat of disbelief before the charge.
Windup=0;RangedWindup=false;ThrowRecovery=0;stagger=.45f;LastImpulse=shooter.IsValid() ? (point-shooter.WorldPosition).WithZ(0).Normal : WorldRotation.Backward;
RouteVia=null;attackClock=.4f;rethink=0;
s.OverdriveKill(shooter,25,point);
return 1;
}
// Ray against every carried stack (a skill shot, no assist). Host only.
internal static OverdriveEnemy TraceStack(Scene scene,Vector3 origin,Vector3 direction,float distance,out Vector3 point)
{
point=default;OverdriveEnemy best=null;float bestAlong=distance;
if(!MultiplayerSession.Authority)return null;
foreach(var e in scene.GetAllComponents<OverdriveEnemy>())
{
if(e.Dead || !e.Carrying)continue;
var c=e.StackCenter;float along=Vector3.Dot(c-origin,direction);
if(along<0 || along>bestAlong)continue;
// Stack is about 11 units across and a few plates tall.
if((c-(origin+direction*along)).Length>7.5f)continue;
best=e;bestAlong=along;point=origin+direction*along;
}
return best;
}
private bool sputterStarted;
public static OverdriveEnemy Find(GameObject go)
{while(go.IsValid()){var enemy=go.Components.Get<OverdriveEnemy>();if(enemy.IsValid())return enemy;go=go.Parent;}return null;}
internal static SceneTraceResult TraceIntact(SceneTrace trace)
{
SceneTraceResult closest=default;
foreach(var hit in trace.RunAll())
{
var enemy=Find(hit.GameObject);
if(!hit.Hit || !enemy.IsValid() || enemy.Dead)continue;
int part=enemy.Zone(hit.HitPosition,hit.Hitbox?.Bone?.Name);
if(part!=0 && (enemy.MissingParts&part)!=0)continue;
if(!closest.Hit || hit.Fraction<closest.Fraction)closest=hit;
}
return closest;
}
internal void PrepareComponents()
{
if(!Components.Get<OverdriveRobotRig>().IsValid())Components.Create<OverdriveRobotRig>();
if(!Components.Get<SausageEmbeddedBolts>().IsValid())Components.Create<SausageEmbeddedBolts>();
var collider=Components.Get<BoxCollider>() ?? Components.Create<BoxCollider>();collider.Center=Vector3.Up*37;collider.Scale=new Vector3(24,34,74);collider.Static=false;
Agent=Components.Get<NavMeshAgent>() ?? Components.Create<NavMeshAgent>();Agent.Height=74;Agent.Radius=17;Agent.MaxSpeed=205;Agent.Acceleration=750;
}
protected override void OnStart()
{
GameObject.Tags.Add("overdrive_enemy");PrepareComponents();_=CryoBurst.Load();
Agent.UpdatePosition=!IsProxy && !Dead;Agent.UpdateRotation=false;shownParts=MissingParts;shownHeadHits=HeadHits;
}
protected override void OnUpdate()
{
UpdateCryoPresentation();
bool headHitChanged=shownHeadHits!=HeadHits;
shownHeadHits=HeadHits;
if(headHitChanged || ((shownParts&1)==0 && (MissingParts&1)!=0))
{
var rig=Components.Get<OverdriveRobotRig>();
var point=rig?.Body.IsValid()==true ? rig.IntactPose("head").Position : WorldPosition+Vector3.Up*68;
OverdriveHeadBurst.Spawn(Scene,point,LastImpulse,Round+(int)WorldPosition.x*11+(int)WorldPosition.y*7+HeadHits,HeadBreakVariant-2);
}
if(shownParts!=MissingParts)
{
int lost=MissingParts&~shownParts;shownParts=MissingParts;
foreach(int part in new[]{2,4,8,16})if((lost&part)!=0)
OverdriveFragment.Spawn(Components.Get<OverdriveRobotRig>()?.Body,part,LastImpulse);
if((lost&30)!=0){OverdriveAudio.Play("limb",AimPoint);}
}
if(HollowTorso && !shownHollow)
{
shownHollow=true;OverdriveHeadBurst.Spawn(Scene,WorldPosition+Vector3.Up*40,LastImpulse,Round+37);
OverdriveAudio.Play("sputter",AimPoint);
}
if(HeadlessStagger)
{
if(!sputterStarted){sputterStarted=true;sputter=OverdriveAudio.Play("sputter",AimPoint);}
if(sputter is not null)
{
sputter.Position=AimPoint;
}
}
else if(sputter is not null){sputter.Stop();sputter=null;}
if(!MultiplayerSession.Authority)return;
var session=MultiplayerSession.Find(Scene);
if(session?.IsOverdrive!=true || !session.InRound || session.RoundId!=Round){GameObject.Destroy();return;}
if(Dead)
{
Agent.Stop();Agent.UpdatePosition=false;deadTime+=Time.Delta;
if(HeadlessStagger)
{
// Continue the last gait briefly, but never attack, retarget or walk through cover.
var direction=Rotation.FromYaw(MathF.Sin(deadTime*13)*12)*stumbleForward;
var start=WorldPosition+Vector3.Up*36;
var end=start+direction*(55+50*CollapseRemaining)*Time.Delta;
var hit=Scene.Trace.Sphere(19,start,end).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
if(!hit.Hit && Scene.NavMesh.GetClosestPoint(end-Vector3.Up*36,24) is {} floor && MathF.Abs(floor.z-WorldPosition.z)<20)
WorldPosition=new Vector3(end.x,end.y,floor.z+3);
else CollapseRemaining=MathF.Min(CollapseRemaining,.18f);
CollapseRemaining=MathF.Max(0,CollapseRemaining-Time.Delta);
}
if(deadTime>5)GameObject.Destroy();return;
}
if(session.OverdriveReviewPaused){Agent.Stop();return;}
if(TickFreeze())return;
if(session.OverdrivePhase=="READY")
{
Windup=ThrowRecovery=0;RangedWindup=false;Agent.MaxSpeed=85*(HeadWounded ? .6f : 1);
if(RouteVia is {} patrol)Agent.MoveTo(patrol);else Agent.Stop();
return;
}
slipImmunity=MathF.Max(0,slipImmunity-Time.Delta);
if(SlipRemaining>0)
{
Agent.Stop();SlipRemaining=MathF.Max(0,SlipRemaining-Time.Delta);
// A short grounded skid, swept against geometry; never a teleport.
float elapsed=SlipDuration-SlipRemaining;
if(elapsed<.42f)
{
var start=WorldPosition+Vector3.Up*36;
var end=start+WorldRotation.Forward*(150*(1-elapsed/.42f))*Time.Delta;
// Slips affect this enemy only; close crowds must not domino.
var hit=Scene.Trace.Sphere(18,start,end).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
if(!hit.Hit && Scene.NavMesh.GetClosestPoint(end-Vector3.Up*36,20) is {} floor && MathF.Abs(floor.z-WorldPosition.z)<12)
Agent.SetAgentPosition(new Vector3(end.x,end.y,floor.z+3));
}
return;
}
rethink-=Time.Delta;attackClock-=Time.Delta;stagger=MathF.Max(0,stagger-Time.Delta);ThrowRecovery=MathF.Max(0,ThrowRecovery-Time.Delta);
bool targetLost=!target.IsValid() || !target.AcceptHits || target.Components.Get<CloseCombat>()?.Incapacitated==true;
if(targetLost){Windup=0;RangedWindup=false;}
// Finish a telegraphed throw at its original target. Retargeting midway
// used to turn the torso while the projectile still used the old aim.
bool rageValid=Enraged && rageTarget.IsValid() && rageTarget.AcceptHits && rageTarget.Components.Get<CloseCombat>()?.Incapacitated!=true;
if(rageValid){target=rageTarget;targetLost=false;RouteVia=null;}
else if((rethink<=0 && Windup<=0 && ThrowRecovery<=0) || targetLost)
{
target=Scene.GetAllComponents<PaintballCombatant>().Where(p=>p.AcceptHits && p.Components.Get<CloseCombat>()?.Incapacitated!=true)
.OrderBy(p=>(p.WorldPosition-WorldPosition).Length*(p.Components.Get<NetworkPawn>().IsValid() ? .6f : 1)).FirstOrDefault();
rethink=.6f;
}
if(!target.IsValid()){Agent.Stop();return;}
var delta=(target.WorldPosition-WorldPosition).WithZ(0);
var facing=RangedWindup ? (throwAim-WorldPosition).WithZ(0) : delta;
if(facing.Length>1)WorldRotation=Rotation.Slerp(WorldRotation,Rotation.LookAt(facing.Normal),1-MathF.Exp(-10*Time.Delta));
Agent.MaxSpeed=MovementSpeed;
if(stagger>0)
{
Agent.Stop();var start=WorldPosition+Vector3.Up*36;
var end=start+LastImpulse.WithZ(0).Normal*(90*stagger/.28f)*Time.Delta;
var push=Scene.Trace.Sphere(17,start,end).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
if(!push.Hit)Agent.SetAgentPosition(end-Vector3.Up*36);return;
}
// Clear the assigned doorway before stopping to throw. A nearby squad
// used to make every thrower wait inside the same narrow opening.
if(RouteVia is {} via && delta.Length>=60)
{
if((via-WorldPosition).WithZ(0).Length<30)RouteVia=null;
else
{
var door=Scene.NavMesh.GetClosestPoint(via,40);
if(door.HasValue)
{
var direction=(via-WorldPosition).WithZ(0).Normal;
WorldRotation=Rotation.Slerp(WorldRotation,Rotation.LookAt(direction),1-MathF.Exp(-10*Time.Delta));
Agent.MoveTo(door.Value);return;
}
RouteVia=null;
}
}
if(Windup>0)
{
Agent.Stop();Windup=MathF.Max(0,Windup-Time.Delta);
if(Windup==0 && RangedWindup)
{
if(SausageProjectile.Launch(this,throwAim,ThrowPlate,target.GameObject,ThrowEgg))
{
if(ThrowPlate && PlatesCarried>0)PlatesCarried--;
else if(ThrowEgg && EggsCarried>0)EggsCarried--;
}
RangedWindup=false;ThrowRecovery=.6f;
}
else if(Windup==0 && delta.Length<74)
{
var hit=Scene.Trace.Ray(AimPoint,target.EyePosition-Vector3.Up*14).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
if(!hit.Hit || PaintballCombatant.Find(hit.GameObject)==target)
{
if(ArenaShield.TryAbsorbMelee(target,WorldPosition)==ArenaShield.MeleeResult.None)
target.ReceiveOverdriveDamage((MissingParts&6)!=0 ? 15 : Enraged ? 36 : IsRusher ? 32 : 28,WorldPosition);
}
}
return;
}
if(ThrowRecovery>.25f){Agent.Stop();return;}
if(AttackStyle>0 && !rageValid && (MissingParts&6)!=6 && delta.Length>150 && delta.Length<850)
{
var lane=Scene.Trace.Ray(AimPoint,target.EyePosition-Vector3.Up*15).WithSurfaceMeshes().IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris","overdrive_enemy").Run();
if(!lane.Hit || PaintballCombatant.Find(lane.GameObject)==target)
{
if(attackClock<=0)
{
throwAim=target.EyePosition-Vector3.Up*15;
// Shield carriers face a varied mix, with the same warning and fair flight time.
bool shielded=Scene.GetAllComponents<ArenaShield>().Any(x=>x.Owner==target && x.Remaining>0);
// Eggs are a regular part of every thrower's mix, not only one style.
ThrowEgg=AttackStyle==3 ? EggsCarried>0 : Game.Random.Float(0,1)<(AttackStyle==1 ? .4f : .25f);
// Plates come off the carried stack; an empty-handed thrower uses knives.
ThrowPlate=!ThrowEgg && (shielded ? ++shieldThrowCount%2==0 : AttackStyle==2) && (AttackStyle!=2 || PlatesCarried>0);
Windup=ThrowWindup;RangedWindup=true;attackClock=3.4f+AttackStyle*.2f;
Agent.Stop();return;
}
if(delta.Length<580){Agent.Stop();return;}
}
}
if(delta.Length<60 && attackClock<=0){Windup=HeadbuttTime;attackClock=Enraged || IsRusher ? 1.1f : 1.8f;DroneAudio.Play("charge",AimPoint);Agent.Stop();return;}
// Stop at arm's length instead of walking into the fighter (they no
// longer physically collide, so this keeps sausages from overlapping).
if(delta.Length<48){Agent.Stop();return;}
var destination=Scene.NavMesh.GetClosestPoint(target.WorldPosition,150);
if(destination.HasValue)Agent.MoveTo(destination.Value);else Agent.Stop();
}
public int Zone(Vector3 point,string hitBone=null)
{
if(!string.IsNullOrEmpty(hitBone))return OverdriveRobotRig.Part(hitBone);
var p=WorldTransform.PointToLocal(point);
return p.z>48 ? 1 : p.z<15 ? (p.y>0 ? 8 : 16) : MathF.Abs(p.y)>10 ? (p.y>0 ? 2 : 4) : 0;
}
public bool Damage(int damage,int part,Vector3 impulse,PaintballCombatant shooter,Vector3? hitPoint=null)
=>ApplyDamage(damage,part,impulse,shooter,hitPoint,false);
// Head detonations count as head hits; embedding does no damage. Limb/torso
// blasts never turn accumulated projectile hits into a whole-body kill.
public bool ExplodeBoltPart(int part,Vector3 impulse,PaintballCombatant shooter,Vector3 point)
{
if(part==0)BlastTorso(60);
return ApplyDamage(40,part,impulse,shooter,point,part!=1);
}
private bool ApplyDamage(int damage,int part,Vector3 impulse,PaintballCombatant shooter,Vector3? hitPoint,bool localized)
{
var session=MultiplayerSession.Find(Scene);
if(!MultiplayerSession.Authority || Dead || damage<=0 || session?.IsOverdrive!=true || !session.InRound || Round!=session.RoundId)return false;
if(FrozenSolid)return ShatterFrozen(impulse,shooter,part==1);
LastImpulse=impulse;ReactionSerial++;stagger=.28f;Windup=0;RangedWindup=false;ThrowRecovery=0;
if(part==1)
{
// One call represents one trigger pull or one bolt detonation.
// Keep the surviving half targetable until a second head hit.
HeadHits=Math.Min(2,HeadHits+1);stagger=.55f;
if(HeadHits==1)
{
HeadImpactLocal=WorldTransform.PointToLocal(hitPoint ?? WorldPosition+Vector3.Up*68);
var incoming=WorldRotation.Inverse*impulse;
HeadBreakVariant=MathF.Abs(HeadImpactLocal.y)>1 ? (HeadImpactLocal.y>0 ? 2 : 3) : incoming.y>0.2f ? 3 : 2;
Health=Math.Max(1,Health-Math.Min(damage*2,Health/2));
}
else{MissingParts|=1;Health=0;}
}
else if(part!=0 && (MissingParts&part)==0)
{
limbDamage[part]=limbDamage.GetValueOrDefault(part)+damage;
if(limbDamage[part]>=32)
{
MissingParts|=part;
stagger=.55f;
// Losing the arm that carries the stack drops and smashes it.
if(part==2 && Carrying){PlatesCarried=0;EggsCarried=0;StackBreakPoint=AimPoint;StackBreakSerial++;}
}
}
if(!localized && part!=1)
{
Health=Math.Max(0,Health-damage);
if((MissingParts&1)!=0 || (MissingParts&24)==24)Health=0;
}
session.RiotHit(shooter);
if(Health==0)
{
stumbleForward=WorldRotation.Forward.WithZ(0).Normal;
CollapseRemaining=(MissingParts&1)!=0 && (MissingParts&24)==0 && SlipRemaining<=0 ? .95f : 0;
Dead=true;SlipRemaining=0;Components.Get<BoxCollider>().Enabled=false;Agent?.Stop();
if(shooter.IsValid())shooter.RegisterSausageKill(part==1);
if(!MultiplayerSession.Online && session.LastSausageOfEncounter(this))FinalKillSlowMo.Start(Scene);
session.OverdriveKill(shooter,part==1 ? 75 : 40,WorldPosition);
}
return true;
}
internal bool GrenadeHit(Vector3 impulse,PaintballCombatant shooter)
{
if(Dead || !MultiplayerSession.Authority)return false;
// One authoritative death and score; the existing segmented rig and
// textured fragments present the dismemberment on every peer.
if(FrozenSolid)return ShatterFrozen(impulse,shooter,false);
if(!Damage(Health,0,impulse,shooter))return false;
MissingParts|=31;CollapseRemaining=0;return true;
}
internal bool KnockOut(Vector3 impulse,PaintballCombatant shooter)
=>!Dead && Damage(Health,0,impulse,shooter);
internal bool TrySlip(bool bowled=false)
{
var s=MultiplayerSession.Find(Scene);
if(!MultiplayerSession.Authority || Dead || SlipRemaining>0 || slipImmunity>0 || (MissingParts&24)!=0 || s?.IsOverdrive!=true || !s.InRound || Round!=s.RoundId)return false;
// One comic accident at a time; other enemies keep pressure on the team.
if(!bowled && Scene.GetAllComponents<OverdriveEnemy>().Count(e=>e!=this && e.SlipRemaining>0)>=2)return false;
SlipRemaining=SlipDuration;slipImmunity=5;SlipSerial++;Slips++;
Windup=0;RangedWindup=false;ThrowRecovery=0;attackClock=2;Agent?.Stop();return true;
}
protected override void OnDestroy(){sputter?.Stop();}
}