MultiplayerSession.Spawning.cs
using Sandbox;
using System;
namespace PaintballBaddies;
public sealed partial class MultiplayerSession
{
public sealed class StartPose { public Vector3 Position { get; set; } public Rotation Rotation { get; set; } }
[Sync(SyncFlags.FromHost)] public string RoundStarts { get; private set; } = "";
private string observedStarts;
private StartPose[] cachedStarts = [];
public bool LocalArenaReady
{
get
{
if(Scene.GetAllComponents<MapInstance>().Any(m=>m.Active && !m.IsLoaded))return false;
var venue=Scene.GetAllComponents<RestaurantArena>().FirstOrDefault();
if(!venue.IsValid())return true;
return venue.Ready && venue.SourcePrepared && string.IsNullOrEmpty(venue.Error)
&& (!venue.EnableServiceExpansion || Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault()?.Ready==true);
}
}
// Restaurant.Ready precedes the delivery bay's cloud downloads and the
// doorway navigation scan. Never start the countdown in that interval.
public string SpawnReadinessMessage
{
get
{
if(Scene.GetAllComponents<MapInstance>().Any(m=>m.Active && !m.IsLoaded))
return "The arena is still loading. Play will be ready shortly.";
var venue=Scene.GetAllComponents<RestaurantArena>().FirstOrDefault();
if(venue.IsValid())
{
if(!string.IsNullOrEmpty(venue.Error))return venue.Error;
if(!venue.Ready || !venue.SourcePrepared)return "The restaurant is still loading. Play will be ready shortly.";
if(venue.EnableServiceExpansion)
{
var service=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault();
if(service.IsValid() && !string.IsNullOrEmpty(service.Error))return service.Error;
if(service?.Ready!=true)return "Preparing the kitchen and loading dock…";
}
if(Scene.GetAllComponents<KitchenDoorways>().FirstOrDefault()?.Routes.Count is not > 0)
return "Preparing the kitchen entrances…";
}
return Scene.NavMesh.IsGenerating ? "Preparing safe arena paths…" : "";
}
}
internal StartPose StartFor(int seat)
{
if(!IsOverdrive || !InRound || string.IsNullOrEmpty(RoundStarts))return null;
if(observedStarts!=RoundStarts){observedStarts=RoundStarts;cachedStarts=Json.Deserialize<StartPose[]>(RoundStarts);}
return seat>=0 && seat<cachedStarts.Length ? cachedStarts[seat] : null;
}
private IEnumerable<StartPose> StartCandidates()
{
foreach(var p in ArenaMap.Spawns(Scene))yield return new(){Position=p.WorldPosition,Rotation=p.WorldRotation};
foreach(var p in Scene.GetAllComponents<SpawnPoint>().Where(p=>p.GameObject.Active && p.GameObject.Name.StartsWith("PB Alternate ")))
yield return new(){Position=p.WorldPosition,Rotation=p.WorldRotation};
}
internal bool ClearStart(Vector3 p)
{
bool floor=Scene.Trace.Ray(p+Vector3.Up*8,p-Vector3.Up*10).WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit;
var hit=Scene.Trace.Capsule(new Capsule(Vector3.Up*18,Vector3.Up*58,16),p,p)
.WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run();
return floor && !hit.Hit && !hit.StartedSolid;
}
private void RollRoundStarts()
{
RoundStarts="";if(!IsOverdrive)return;
var selected=new List<StartPose>();
foreach(var p in StartCandidates().OrderBy(_=>Game.Random.Float(0,1)))
{
if(!ClearStart(p.Position) || selected.Any(s=>(s.Position-p.Position).Length<72))continue;
selected.Add(p);if(selected.Count==6)break;
}
if(selected.Count==6)RoundStarts=Json.Serialize(selected);
else Log.Warning($"ROUND_STARTS only {selected.Count} clear starts; preserving authored start positions");
}
private StartPose JoiningStart(int seat)
{
if(Mode!=ArenaGameMode.Overdrive && !(IsOverdrive && InRound))
return new(){Position=ArenaMap.PlayerSpawn(Scene,seat,Spawns[seat]),Rotation=ArenaMap.SpawnRotation(Scene,seat,Rotation.Identity)};
if(!string.IsNullOrEmpty(SpawnReadinessMessage))return null;
// The reserved seat's AI remains alive while this connection waits. It
// is replaced only after validation, so its current space is available.
var allies=Scene.GetAllComponents<PaintballCombatant>().Where(a=>a.GameObject.Active
&& a.Components.Get<NetworkBot>()?.Seat!=seat).ToArray();
var enemies=OverdriveEnemies.ToArray();
return StartCandidates().Where(p=>ClearStart(p.Position)
&& !allies.Any(a=>(a.WorldPosition-p.Position).Length<72)
&& !enemies.Any(e=>(e.WorldPosition-p.Position).Length<240))
.OrderBy(p=>enemies.Count(e=>!Scene.Trace.Ray(e.AimPoint,p.Position+Vector3.Up*55)
.WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit))
.ThenBy(p=>allies.Select(a=>(a.WorldPosition-p.Position).Length).DefaultIfEmpty(0).Min())
.FirstOrDefault();
}
private Vector3 SpawnCameraOrigin(PlayerController player,Vector3 offset)
{
var eye=player.EyePosition;var look=player.EyeAngles.ToRotation();
var desired=eye-look.Forward*offset.x+look.Right*offset.y+look.Up*offset.z;
var trace=Scene.Trace.Sphere(6,eye,desired).WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run();
return trace.Hit ? trace.EndPosition+trace.Normal*7 : desired;
}
private IEnumerable<Vector3> SpawnViewOrigins(PlayerController player)
{
var eye=player.EyePosition;
yield return eye;
yield return SpawnCameraOrigin(player,player.CameraOffset);
// A remote pawn does not run its local camera smoothing. Cover both
// shoulders and focus distances using its synchronized look angles.
foreach(float shoulder in new[]{-1f,1f})
{
yield return SpawnCameraOrigin(player,new Vector3(98,28*shoulder,8));
yield return SpawnCameraOrigin(player,new Vector3(55,22*shoulder,8));
}
if(!player.IsProxy && Scene.Camera.IsValid())yield return Scene.Camera.WorldPosition;
// Conservative samples also protect a turn made on the spawning frame.
foreach(var offset in new[]{Vector3.Forward*105,Vector3.Backward*105,Vector3.Left*75,Vector3.Right*75})
{
var trace=Scene.Trace.Sphere(6,eye,eye+offset).WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run();
yield return trace.Hit ? trace.EndPosition+trace.Normal*7 : eye+offset;
}
}
// The largest arrival pose (including the hefty body, outstretched arms,
// plate stack and open egg carton), with room for its first animated frame.
internal static readonly BBox ArrivalBounds=new(new Vector3(-42,-42,-2),new Vector3(42,42,100));
private static readonly Vector3[] arrivalSamples=BuildArrivalSamples();
private static Vector3[] BuildArrivalSamples()
{
var points=new List<Vector3>();
foreach(float z in new[]{-2f,34f,67f,100f})
foreach(float x in new[]{-42f,0f,42f})
foreach(float y in new[]{-42f,0f,42f})points.Add(new Vector3(x,y,z));
return points.ToArray();
}
private static bool SeeThroughName(string value)
{
value=value?.ToLowerInvariant() ?? "";
return value.Contains("glass") || value.Contains("window") || value.Contains("fence")
|| value.Contains("chainlink") || value.Contains("chain_link") || value.Contains("grate")
|| value.Contains("invisible") || value.Contains("player barrier");
}
private static bool SeeThroughMaterial(Material material)
=>material is not null && (material.Flags.IsTranslucent || material.Flags.IsAlphaTest || material.Flags.IsSky
|| SeeThroughName(material.Name) || SeeThroughName(material.ShaderName));
private static bool OpaqueArrivalBlocker(SceneTraceResult hit)
{
if(!hit.Hit || hit.StartedSolid || !hit.GameObject.IsValid())return false;
if(SeeThroughName(hit.Surface?.ResourcePath) || SeeThroughName(hit.GameObject.Name))return false;
var renderer=hit.Component as ModelRenderer ?? hit.GameObject.Components.Get<ModelRenderer>();
if(renderer.IsValid())
{
if(renderer.Tint.a<.99f || SeeThroughMaterial(renderer.MaterialOverride) || RestaurantArrivalGlass.Contains(renderer,hit.EndPosition))return false;
// A combined world contains both opaque and glass slots. Its per-hit
// physics surface (or recorded glass part bounds) selects the window;
// do not classify its brickwork
// as glass simply because another triangle has a glass material.
if(renderer.Materials.Count==1 && SeeThroughMaterial(renderer.Materials.GetOverride(0) ?? renderer.Materials.GetOriginal(0)))return false;
if(SeeThroughName(renderer.Model?.Name))return false;
}
return true;
}
private bool ArrivalHiddenFrom(Vector3 origin,Vector3 point,ArenaShield[] shields)
{
foreach(var sample in arrivalSamples)
{
// Physics surfaces are available in packaged games too. Editor-only
// render-mesh traces previously gave different concealment results.
var trace=Scene.Trace.Ray(origin,point+sample)
.WithoutTags("paintball_actor","overdrive_enemy","paintball_debris","pop_target",RestaurantBathroomEntrance.BarrierTag);
foreach(var shield in shields)if(shield.IsValid())trace=trace.IgnoreGameObjectHierarchy(shield.GameObject);
// Reject uncertain or transparent first hits. A solid wall beyond
// glass may also conceal an arrival, but waiting is preferable to a
// visible pop-in. Never relax this check after repeated attempts.
if(!OpaqueArrivalBlocker(trace.Run()))return false;
}
return true;
}
internal bool SpawnHidden(Vector3 point)
{
var humans=Scene.GetAllComponents<PlayerController>().Where(p=>p.Active).ToArray();
if(humans.Length==0)return false;
var shields=Scene.GetAllComponents<ArenaShield>().ToArray();
// Includes host and every remote NetworkPawn's synchronized eye/look.
// No FOV test: a human can turn freely or change camera shoulder.
foreach(var p in humans)
foreach(var origin in SpawnViewOrigins(p))
if(!ArrivalHiddenFrom(origin,point,shields))return false;
return true;
}
}