MultiplayerSession.Overdrive.cs
using Sandbox;
using System;
namespace PaintballBaddies;
public sealed partial class MultiplayerSession
{
public bool IsOverdrive=>ActiveMode==ArenaGameMode.Overdrive;
public bool IsCooperative=>IsPopRiot || IsOverdrive;
[Sync(SyncFlags.FromHost)] public string OverdrivePhase {get;private set;}="";
[Sync(SyncFlags.FromHost)] public int OverdriveWave {get;private set;}
[Sync(SyncFlags.FromHost)] public int OverdriveRemaining {get;private set;}
[Sync(SyncFlags.FromHost)] public int OverdriveUpgrades {get;private set;}
[Sync(SyncFlags.FromHost)] public float OverdrivePhaseSeconds {get;private set;}
[Sync(SyncFlags.FromHost)] public string OverdriveResult {get;private set;}="";
[Sync(SyncFlags.FromHost)] public Vector3 ExtractionPoint {get;private set;}
[Sync(SyncFlags.FromHost)] public float ExtractionHold {get;private set;}
[Sync(SyncFlags.FromHost)] public float OverdriveWipe {get;private set;}
internal bool OverdriveReviewPaused {get;set;}
public bool OverdriveWon=>OverdrivePhase=="COMPLETE";
private int overdriveSpawned,overdriveQuota,overdriveSeed,restaurantEntrance;
private float overdriveSpawnClock,overdriveBlocked;
private int openingSausages;
public IEnumerable<OverdriveEnemy> OverdriveEnemies=>Scene.GetAllComponents<OverdriveEnemy>().Where(e=>!e.Dead && e.Round==RoundId);
private void StartOverdriveRound()
{
ConcreteFractures="";
foreach(var e in Scene.GetAllComponents<OverdriveEnemy>().ToArray())e.GameObject.Destroy();
foreach(var e in Scene.GetAllComponents<SausageEggSlick>().ToArray())e.GameObject.Destroy();
OverdrivePhase="";OverdriveResult="";OverdriveWave=OverdriveUpgrades=OverdriveRemaining=0;
OverdriveReviewPaused=false;OverdriveWipe=ExtractionHold=overdriveBlocked=0;
restaurantEntrance=0;restaurantEntryCounts.Clear();openingSausages=0;overdriveSpawnClock=.3f;recentSpawnPoints.Clear();
if(!IsOverdrive)return;
_=OverdriveAssets.Load();
Remaining=300;ActiveMinutes=5;OverdrivePhase="READY";OverdrivePhaseSeconds=5;
overdriveSeed=RoundId*101;RiotBreather=0;
}
private void BeginOverdriveWave()
{
OverdriveWave++;RiotWave=OverdriveWave;OverdrivePhase="FIGHT";
overdriveSpawned=OverdriveWave==1 ? openingSausages : 0;overdriveQuota=6+OverdriveWave*4;OverdriveRemaining=overdriveQuota;
overdriveSpawnClock=0;overdriveBlocked=0;
foreach(var p in Scene.GetAllComponents<PaintballCombatant>())p.HealOverdrive();
}
private void TickOverdrive()
{
if(!Authority || !IsOverdrive || !InRound || OverdriveReviewPaused || Remaining<=0)return;
_=OverdriveAssets.Load();
if(!OverdriveAssets.Ready)
{if(!string.IsNullOrEmpty(OverdriveAssets.Error)){OverdriveResult=OverdriveAssets.Error;Remaining=0;}return;}
if(!string.IsNullOrEmpty(SpawnReadinessMessage)){Notice=SpawnReadinessMessage;return;}
var humans=Players.Select(p=>p.Components.Get<PaintballCombatant>()).Where(p=>p.IsValid()).ToArray();
bool wiped=humans.Length>0 && humans.All(p=>p.Components.Get<CloseCombat>()?.Incapacitated==true);
OverdriveWipe=wiped ? OverdriveWipe+Time.Delta : 0;
if(OverdriveWipe>=12){OverdriveResult="SQUAD DOWN · TRY ANOTHER BUILD";Remaining=0;return;}
if(OverdrivePhase is "READY" or "BREATHER")
{
if(OverdrivePhase=="READY" && openingSausages<3 && !Scene.NavMesh.IsGenerating)
{
overdriveSpawnClock-=Time.Delta;
if(overdriveSpawnClock<=0){if(SpawnOverdriveAttacker())openingSausages++;overdriveSpawnClock=.65f;}
}
OverdrivePhaseSeconds=MathF.Max(0,OverdrivePhaseSeconds-Time.Delta);
if(OverdrivePhaseSeconds<=0)
{
BeginOverdriveWave();
}
return;
}
if(OverdrivePhase!="FIGHT")return;
int alive=OverdriveEnemies.Count();
OverdriveRemaining=overdriveQuota-overdriveSpawned+alive;
if(overdriveSpawned>=overdriveQuota && alive==0 && !RiotTargets.Any() && !Scene.GetAllComponents<PopRiotTarget>().Any(t=>t.Crashing)
&& !Scene.GetAllComponents<OverdriveEnemy>().Any(e=>e.HeadlessStagger))
{
if(OverdriveWave<3)
{
// Short breathers preserve control. Rewards come from each
// player's five headshot kills, not a menu or wave transition.
OverdrivePhase="BREATHER";OverdrivePhaseSeconds=4;
}
else
{
OverdrivePhase="COMPLETE";OverdriveRemaining=0;
OverdriveResult="KITCHEN CLEARED · SAUSAGE SURVIVAL COMPLETE";Remaining=0;
}
return;
}
overdriveSpawnClock-=Time.Delta;
if(overdriveSpawnClock>0 || overdriveSpawned>=overdriveQuota || alive>=7 || Scene.NavMesh.IsGenerating)return;
if(SpawnOverdriveAttacker()){overdriveSpawned++;overdriveBlocked=0;}
else
{
// Watching or occupying an entrance is normal play, not a broken
// round. Keep waiting for an unseen arrival instead of forcing a loss.
overdriveBlocked=MathF.Min(15,overdriveBlocked+1);
}
overdriveSpawnClock=ArenaSelection.Current(Scene)==ArenaSelection.Restaurant ? .85f : 1.2f;
}
private Vector3[] OverdrivePatrolPoints()
{var authored=ArenaMap.PatrolPoints(Scene);return authored.Length>0 ? authored : ArenaOpponent.CreatePatrolRoute();}
private readonly List<Vector3> recentSpawnPoints=new();
// Just inside the dining room, past each of the two openings in the kitchen wall.
internal static readonly Vector3[] RestaurantDoors=[new(400,1360,33),new(400,800,33)];
internal static readonly Vector3[] KitchenEntrances=[new(560,1800,33),new(560,1480,33),new(560,1160,33),new(560,1000,33),new(640,1320,33)];
private bool SpawnOverdriveAttacker()
{
var actors=Scene.GetAllComponents<PaintballCombatant>().Where(a=>a.AcceptHits).ToArray();
var humanPositions=actors.Where(a=>a.Components.Get<NetworkPawn>().IsValid()).Select(a=>a.WorldPosition).ToArray();
bool restaurant=Scene.GetAllComponents<RestaurantArena>().Any();
// Spread arrivals over every entrance and route node instead of always
// using the single farthest point. Prefer points far from the squad and
// out of its sight, and avoid lanes used by the last few spawns, so
// sausages arrive from several directions and can flank.
if(restaurant)return SpawnRestaurantAttacker(actors);
var candidates=(restaurant ? KitchenEntrances.Concat(OverdrivePatrolPoints().Where(p=>p.x>=500)) : OverdrivePatrolPoints()).ToArray();
float minDistance=restaurant ? 420 : 650;
var eyes=humanPositions.Select(h=>h+Vector3.Up*64).ToArray();
var points=candidates.Select(p=>
{
float nearest=humanPositions.Select(h=>(h-p).Length).DefaultIfEmpty(900).Min();
bool seen=eyes.Any(e=>!Scene.Trace.Ray(e,p+Vector3.Up*40).WithoutTags("paintball_actor","paintball_debris","overdrive_enemy","pop_target").Run().Hit);
int recent=recentSpawnPoints.Count(r=>(r-p).Length<260);
float weight=Game.Random.Float(.6f,1.4f)*(seen ? 1 : 1.8f)/(1+recent*1.5f);
return (Point:p,Eligible:nearest>=minDistance,Weight:weight,Nearest:nearest);
}).OrderByDescending(c=>c.Eligible).ThenByDescending(c=>c.Eligible ? c.Weight : c.Nearest).Select(c=>c.Point).ToArray();
foreach(var point in points)
{
var floor=Scene.NavMesh.GetClosestPoint(point,160);if(!floor.HasValue)continue;
if(actors.Any(a=>(a.WorldPosition-floor.Value).Length<(a.Components.Get<NetworkPawn>().IsValid() ? (restaurant ? 130 : 300) : 75)))continue;
if(OverdriveEnemies.Any(e=>(e.WorldPosition-floor.Value).Length<60))continue;
var position=floor.Value+Vector3.Up*3;
if(!SpawnHidden(position))continue;
if(Scene.Trace.Sphere(18,position+Vector3.Up*28,position+Vector3.Up*28)
.WithoutTags("paintball_debris","overdrive_enemy").Run().Hit)continue;
var spawned=SpawnOverdriveRobot(position);
recentSpawnPoints.Add(point);if(recentSpawnPoints.Count>5)recentSpawnPoints.RemoveAt(0);
return true;
}
return false;
}
private readonly Dictionary<string,int> restaurantEntryCounts=new();
public IReadOnlyDictionary<string,int> RestaurantEntryCounts=>restaurantEntryCounts;
private bool SpawnRestaurantAttacker(PaintballCombatant[] actors)
{
var routes=Scene.GetAllComponents<KitchenDoorways>().FirstOrDefault()?.Routes;
if(routes is null)return false;
// An entrance skipped while watched gets first refusal once hidden
// again, instead of permanently losing its share to the nearest door.
var order=Enumerable.Range(0,routes.Count)
.OrderBy(i=>restaurantEntryCounts.GetValueOrDefault(KitchenDoorways.EntryName(routes[i])))
.ThenBy(i=>(i-restaurantEntrance+routes.Count)%routes.Count).ToArray();
foreach(int index in order)
{
var route=routes[index];var entry=KitchenDoorways.EntryName(route);
// Step sideways behind a jamb or use cover in the delivery yard.
var candidates=KitchenDoorways.ConcealedEntryCandidates(route);
foreach(var candidate in candidates.OrderBy(_=>Game.Random.Float(0,1)))
{
var floor=Scene.NavMesh.GetClosestPoint(candidate,35);if(!floor.HasValue)continue;
if(actors.Any(a=>(a.WorldPosition-floor.Value).Length<(a.Components.Get<NetworkPawn>().IsValid() ? 160 : 75)))continue;
if(OverdriveEnemies.Any(e=>(e.WorldPosition-floor.Value).Length<90))continue;
var position=floor.Value+Vector3.Up*2;
if(!SpawnHidden(position))continue;
if(Scene.Trace.Capsule(new Capsule(Vector3.Up*17,Vector3.Up*57,17),position,position)
.WithoutTags("paintball_debris","overdrive_enemy").Run().Hit)continue;
var path=Scene.NavMesh.CalculatePath(new Sandbox.Navigation.CalculatePathRequest{Start=floor.Value,Target=route.Exit});
if(!KitchenDoorways.UsesAssignedOpening(path,route))continue;
var enemy=SpawnOverdriveRobot(position);if(!enemy.IsValid())continue;
enemy.RouteVia=route.Exit;restaurantEntrance=(index+1)%routes.Count;
restaurantEntryCounts[entry]=restaurantEntryCounts.GetValueOrDefault(entry)+1;
Log.Info($"SAUSAGE_ENTRY hidden route={index} entry={entry} count={restaurantEntryCounts[entry]} at={position.x:0},{position.y:0}");return true;
}
}
return false;
}
internal OverdriveEnemy SpawnOverdriveRobot(Vector3 point)
{
if(!Authority || !IsOverdrive || !InRound)return null;
var go=new GameObject(false,"Terry sausage raider"){WorldPosition=point};
var enemy=go.Components.Create<OverdriveEnemy>();enemy.Round=RoundId;
++overdriveSeed;
// One unarmed runner per five arrivals; the round seed shifts his place.
// The remaining enemies keep their knife/plate/egg roles.
enemy.AttackStyle=overdriveSeed%5==0 ? 0 : 1+(overdriveSeed-1)%3;
enemy.SetHefty(!OverdriveReviewPaused && overdriveSeed%5==2);
// Plate throwers carry their ammunition as a stack in the off hand.
if(enemy.AttackStyle==2)enemy.SetPlateStack(Game.Random.Int(7,10));
if(enemy.AttackStyle==3)enemy.SetEggCarton(6);
// Include components in the spawn snapshot. Adding them independently
// on both peers in OnStart created duplicate guest presentation rigs.
enemy.PrepareComponents();go.Enabled=true;
if(Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
return enemy;
}
// True when this death empties the encounter: nothing left alive or to spawn.
internal bool LastSausageOfEncounter(OverdriveEnemy dying)
=>OverdrivePhase=="FIGHT" && overdriveSpawned>=overdriveQuota && !OverdriveEnemies.Any(e=>e!=dying);
internal void OverdriveKill(PaintballCombatant shooter,int points,Vector3 point)
{
if(!Authority || !IsOverdrive || !InRound)return;
TeamScore+=points;
if(shooter.IsValid()){shooter.PopScore+=points;shooter.TargetsPopped++;}
if((OverdriveUpgrades&4)!=0)
foreach(var ally in Scene.GetAllComponents<PaintballCombatant>().Where(a=>(a.WorldPosition-point).Length<190 && a.Components.Get<CloseCombat>()?.Incapacitated!=true))ally.HealOverdrive();
}
}