MultiplayerSession.PopRiot.cs
using Sandbox;
using System;
namespace PaintballBaddies;
// Slot 1 replaces the former coin mode; the existing lobby/timer handles both modes.
public enum ArenaGameMode { PaintRush, PopRiot, Overdrive }
public sealed class PopRiotSpawnPoint : Component { }
public sealed partial class MultiplayerSession
{
[Sync(SyncFlags.FromHost)] public ArenaGameMode Mode {get;private set;}=ArenaGameMode.Overdrive;
[Sync(SyncFlags.FromHost)] public ArenaGameMode ActiveMode {get;private set;}=ArenaGameMode.Overdrive;
[Sync(SyncFlags.FromHost)] public int RiotWave {get;private set;}
[Sync(SyncFlags.FromHost)] public int TeamScore {get;private set;}
[Sync(SyncFlags.FromHost)] public int RiotCombo {get;private set;}
[Sync(SyncFlags.FromHost)] public float ComboSeconds {get;private set;}
[Sync(SyncFlags.FromHost)] public float RiotBreather {get;private set;}
[Sync(SyncFlags.FromHost)] public float RiotOverflow {get;private set;}
[Sync(SyncFlags.FromHost)] public int RiotAlive {get;private set;}
[Sync(SyncFlags.FromHost)] public bool RiotFinale {get;private set;}
[Sync(SyncFlags.FromHost)] public int RiotGiantHealth {get;private set;}
[Sync(SyncFlags.FromHost)] public int RiotChambers {get;private set;}
[Sync(SyncFlags.FromHost)] public bool RiotChambersOpen {get;private set;}
[Sync(SyncFlags.FromHost)] public Vector3 RiotGiantPosition {get;private set;}
[Sync(SyncFlags.FromHost)] public string RiotResult {get;private set;}="";
[Sync(SyncFlags.FromHost)] public int RiotLives {get;private set;}=RiotMaximumLives;
[Sync(SyncFlags.FromHost)] public int RiotWaveTotal {get;private set;}
[Sync(SyncFlags.FromHost)] public int RiotWaveCleared {get;private set;}
[Sync(SyncFlags.FromHost)] public string RiotNotice {get;private set;}="";
[Sync(SyncFlags.FromHost)] public float RiotNoticeSeconds {get;private set;}
public const int RiotMaximumLives=5;
public const int RiotCapacity=12;
public bool RiotWon=>!string.IsNullOrEmpty(RiotResult) && RiotLives>0 && RiotResult.StartsWith("TEAM SURVIVED");
public bool IsPopRiot=>ActiveMode==ArenaGameMode.PopRiot;
public int RiotMultiplier=>1+Math.Min(4,RiotCombo/8);
public static string ModeTitle(ArenaGameMode mode)=>mode==ArenaGameMode.Overdrive ? "SAUSAGE SURVIVAL" : mode==ArenaGameMode.PopRiot ? "POP RIOT" : "PAINT RUSH";
public static string ModeKey(ArenaGameMode mode)=>mode==ArenaGameMode.Overdrive ? "overdrive" : mode==ArenaGameMode.PopRiot ? "pop_riot" : "paint_rush";
public string ModeHelp=>HelpFor(Mode);
public static string HelpFor(ArenaGameMode mode)=>mode==ArenaGameMode.Overdrive ? "Survive three waves together. Use dual pistols and explosive bolts, intercept flying kitchenware, and earn specials with headshot kills. Clear the final wave to win. Friendly fire is off." : mode==ArenaGameMode.PopRiot
? "Shoot moving drones before they shoot you. Intercept their large paintballs, dodge or take cover. Five shared team lives; each wave cleared restores one. Survive until the timer ends. Friendly fire is off."
: "Land paint. Avoid hits. Most points wins. Everyone stays in the fight.";
private Vector3[] riotLocations=Array.Empty<Vector3>();
private float riotSpawnClock,riotSpawnBlocked;
private int riotSpawned,riotQuota,riotSeed;
internal bool RiotReviewPaused {get;set;}
public IEnumerable<PopRiotTarget> RiotTargets=>Scene.GetAllComponents<PopRiotTarget>().Where(t=>!t.Popped && t.Round==RoundId);
public void SelectMode(ArenaGameMode mode)
{
if(!Enum.IsDefined(mode))return;
if(Online){RequestMode(mode);return;}
Mode=mode; if(mode==ArenaGameMode.Overdrive)_=OverdriveAssets.Load();
}
[Rpc.Host]
private async void RequestMode(ArenaGameMode mode)
{
var caller=Rpc.Caller;await GameTask.MainThread();
if(!IsValid || caller!=Connection.Host || !Enum.IsDefined(mode))return;
Mode=mode; if(mode==ArenaGameMode.Overdrive)_=OverdriveAssets.Load();
if(!InRound)Networking.SetData("pb_mode",ModeKey(Mode));
}
private void StartPopRiotRound()
{
ClearRiotTargets();riotLocations=Array.Empty<Vector3>();
RiotWave=TeamScore=RiotCombo=RiotAlive=0;ComboSeconds=RiotOverflow=0;
RiotFinale=false;RiotGiantHealth=RiotChambers=0;RiotChambersOpen=false;RiotGiantPosition=Vector3.Zero;RiotResult="";RiotBreather=3;riotSpawnClock=0;riotSpawned=riotQuota=0;
RiotLives=RiotMaximumLives;RiotWaveTotal=RiotWaveCleared=0;RiotNotice="";RiotNoticeSeconds=riotSpawnBlocked=0;
RiotReviewPaused=false;
if(Online)Networking.SetData("pb_mode",ModeKey(ActiveMode));
}
private void ClearRiotTargets()
{
foreach(var t in Scene.GetAllComponents<PopRiotTarget>().ToArray())t.GameObject.Destroy();
RiotAlive=0;
}
private void TickPopRiot()
{
if(!Authority || !IsPopRiot || !InRound || RiotReviewPaused || Remaining<=0)return;
ComboSeconds=MathF.Max(0,ComboSeconds-Time.Delta);
RiotNoticeSeconds=MathF.Max(0,RiotNoticeSeconds-Time.Delta);
if(ComboSeconds<=0)RiotCombo=0;
if(riotLocations.Length==0)
{
if(Scene.NavMesh.IsGenerating)return;
var authored=Scene.GetAllComponents<PopRiotSpawnPoint>().Select(x=>x.WorldPosition).ToArray();
bool foundry=ArenaSelection.Current(Scene)==ArenaSelection.Foundry;
var candidates=foundry
? new[]{0f,1181.1024f}.SelectMany(y=>Enumerable.Range(-4,9).Select(x=>new Vector3(x*240,y,0)))
: (authored.Length>0 ? authored : ArenaMap.PatrolPoints(Scene))
.SelectMany(p=>new[]{p,p+Vector3.Forward*160,p-Vector3.Forward*160,p+Vector3.Right*160,p-Vector3.Right*160});
riotLocations=candidates.Select(p=>new { Point=p, Floor=Scene.NavMesh.GetClosestPoint(p,150) })
.Where(p=>p.Floor.HasValue).Select(p=>(foundry ? new Vector3(p.Point.x,p.Point.y,p.Floor.Value.z) : p.Floor.Value)+Vector3.Up*156)
.Where(p=>!Scene.Trace.Sphere(42,p+Vector3.Up*(foundry ? 230 : 420),p)
.WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit)
.OrderBy(_=>Game.Random.Float()).ToArray();
if(riotLocations.Length==0)
{
riotSpawnBlocked+=Time.Delta;
if(riotSpawnBlocked>12){RiotResult="NO CLEAR TARGET LANES — RESTART OR CHOOSE ANOTHER ARENA";Remaining=0;}
return;
}
}
RiotAlive=RiotTargets.Count(t=>t.Kind!=PopKind.Glob);
if(RiotBreather>0)
{
RiotBreather=MathF.Max(0,RiotBreather-Time.Delta);
if(RiotBreather<=0)BeginRiotWave();
return;
}
if(riotSpawned>=riotQuota && !RiotTargets.Any() && !Scene.GetAllComponents<PopRiotTarget>().Any(t=>t.Crashing && t.Round==RoundId))
{
CompleteRiotWave();return;
}
riotSpawnClock-=Time.Delta;
int concurrent=riotQuota;
// Never keep spawning beyond the wave quota: the previous mode could not clear naturally.
if(riotSpawnClock>0 || riotSpawned>=riotQuota || RiotAlive>=concurrent)return;
if(SpawnWaveDrone())riotSpawnBlocked=0;
else
{
riotSpawnBlocked+=.65f;
if(riotSpawnBlocked>12 && RiotAlive==0)
{RiotResult="NO CLEAR TARGET LANES — RESTART OR CHOOSE ANOTHER ARENA";Remaining=0;}
}
riotSpawnClock=.9f;
}
private bool SpawnWaveDrone()
{
var actors=Scene.GetAllComponents<PaintballCombatant>().Where(a=>a.AcceptHits).ToArray();
var humans=actors.Where(a=>a.Components.Get<NetworkPawn>().IsValid()).ToArray();
var viewers=humans.Length>0 ? humans : actors;
// Prefer lanes players can actually see, without appearing at their feet or inside another target.
foreach(var p in riotLocations.OrderBy(_=>Game.Random.Float()).OrderBy(p=>
viewers.Any(a=>(a.WorldPosition-p).Length<1500 && !Scene.Trace.Ray(p,a.EyePosition)
.WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit) ? 0 : 1))
{
var point=p;
if(actors.Any(a=>(a.EyePosition-p).Length<75) || RiotTargets.Any(t=>(t.WorldPosition-point).Length<130))continue;
var entry=point+Vector3.Up*(ArenaSelection.Current(Scene)==ArenaSelection.Foundry ? 230 : 420);
if(RiotTargets.Any(t=>(t.WorldPosition-entry).Length<100))continue;
var drone=SpawnRiotTarget(entry,PopKind.Blob);
if(drone.IsValid()){drone.Anchor=point;drone.Entering=true;riotSpawned++;return true;}
}
return false;
}
private void BeginRiotWave()
{
RiotWave++;riotSpawned=0;riotQuota=Math.Min(6,3+RiotWave/2);
RiotWaveTotal=riotQuota;RiotWaveCleared=0;riotSpawnClock=riotSpawnBlocked=0;
SpawnWaveDrone();riotSpawnClock=.9f;
}
internal void CompleteRiotWave()
{
if(!Authority || !IsPopRiot || !InRound || Remaining<=0 || RiotBreather>0)return;
bool restored=RiotLives<RiotMaximumLives;
RiotLives=Math.Min(RiotMaximumLives,RiotLives+1);
RiotNotice=restored ? "WAVE CLEAR · +1 TEAM LIFE" : "WAVE CLEAR · TEAM LIVES FULL";
RiotNoticeSeconds=6;RiotBreather=6;
NetworkEffects.RiotCue("bank_move");
}
internal bool LoseRiotLife(PaintballCombatant actor)
{
if(!Authority || !IsPopRiot || !InRound || Remaining<=0 || RiotLives<=0 || !actor.IsValid() || !actor.AcceptHits)return false;
RiotLives--;RiotCombo=0;ComboSeconds=0;
var name=actor.Components.Get<PlayerProfile>()?.DisplayName
?? RosterSelection.Names[actor.Components.Get<ArenaOpponent>()?.CharacterIndex ?? 0];
RiotNotice=$"{name.ToUpperInvariant()} HIT · -1 TEAM LIFE";RiotNoticeSeconds=2.5f;
NetworkEffects.RiotCue("spill");
if(RiotLives==0){RiotResult="TEAM OUT OF LIVES — ONE MORE ROUND?";Remaining=0;}
return true;
}
internal PopRiotTarget SpawnRiotTarget(Vector3 position,PopKind kind)
{
if(!Authority || !IsCooperative || !InRound || Remaining<=0)return null;
if(kind==PopKind.Glob ? RiotTargets.Count(t=>t.Kind==PopKind.Glob)>=10 : RiotTargets.Count(t=>t.Kind!=PopKind.Glob)>=RiotCapacity)return null;
float radius=PopRiotTarget.SizeFor(kind);
if(Scene.Trace.Sphere(radius,position,position+Vector3.Up*.1f).WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit)return null;
var go=new GameObject(false,$"Pop Riot {kind}"){WorldPosition=position};
var t=go.Components.Create<PopRiotTarget>();t.Kind=kind;t.Round=RoundId;t.Seed=++riotSeed;
t.InitializeHealth();t.Anchor=position;
go.Enabled=true;
if(Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
return t;
}
internal void RiotHit(PaintballCombatant shooter)
{
if(!Authority || !InRound || !IsCooperative || !shooter.IsValid())return;
RiotCombo++;ComboSeconds=3.5f;shooter.PopHits++;
if(Online)shooter.Components.Get<NetworkPawn>()?.ConfirmHit();
}
internal void RiotMiss()
{
if(!Authority || !InRound || !IsCooperative)return;
RiotCombo=Math.Max(0,RiotCombo-2);
}
internal void RiotPop(PopRiotTarget target,PaintballCombatant shooter,bool chained)
{
if(!Authority || !InRound || !IsCooperative)return;
int points=(target.Kind==PopKind.Giant ? 150 : target.Kind==PopKind.Blob ? 30 : target.Kind==PopKind.Glob ? 5 : 10)*RiotMultiplier;
if(IsOverdrive){OverdriveKill(shooter,points,target.WorldPosition);return;}
TeamScore+=points;
if(shooter.IsValid()){shooter.PopScore+=points;shooter.TargetsPopped++;}
if(target.Kind==PopKind.Blob)RiotWaveCleared=Math.Min(RiotWaveTotal,RiotWaveCleared+1);
var tint=target.PaintTint;
// A disabled drone keeps its airframe until the falling flight reaches an impact.
if(target.Kind!=PopKind.Blob)
{
NetworkEffects.RiotBurst(target.WorldPosition,target.Radius,tint,target.Kind==PopKind.Giant,chained,RoundId);
var floor=Scene.Trace.Ray(target.WorldPosition,target.WorldPosition-Vector3.Up*240)
.WithoutTags("paintball_actor","paintball_debris","pop_target").Run();
if(floor.Hit)PaintImpactSystem.Find(Scene)?.Spawn(floor,tint);
}
if(target.Kind==PopKind.Chain)
foreach(var next in RiotTargets.Where(t=>t!=target && t.Kind!=PopKind.Giant && (t.WorldPosition-target.WorldPosition).Length<235).ToArray())
{
var sight=Scene.Trace.Ray(target.WorldPosition,next.WorldPosition).WithoutTags("paintball_actor","paintball_debris","pop_target").Run();
if(!sight.Hit)next.Pop(shooter,true);
}
}
}