KitchenDoorways.cs
using Sandbox;
using System;
namespace PaintballBaddies;
/// <summary>Last Orders: finds the real openings in the wall between the dining
/// room and the kitchen, frames each one so it reads as a door from the dining
/// room, and hands their positions to sausage routing. Runs on every peer; the
/// scan only reads collision, so all peers find the same openings.</summary>
public sealed class KitchenDoorways : Component
{
// Wall between dining room (west) and kitchen (east), from the layout probe.
const float WallX=460,ScanFrom=380,ScanTo=545,Floor=33;
private readonly List<Vector3> doors=new();
private readonly List<(float Y0,float Y1,float Top)> spans=new();
public readonly List<(Vector3 Spawn,Vector3 Exit)> Routes=new();
public bool HasDockRoute=>Routes.Any(r=>r.Spawn.x>800);
public bool HasBathroomRoute=>Routes.Any(r=>r.Exit.y<1000);
private float wait,stableNav,dockRetry;private bool done;
private GameObject dressing;
public void Refresh()
{
done=false;wait=0;stableNav=0;dockRetry=0;Routes.Clear();
}
protected override void OnUpdate()
{
if(ArenaSelection.Current(Scene)!=ArenaSelection.Restaurant)return;
if(done)
{
// Late map collision and kitchen links can update navigation after
// the first scan. Retain working routes and retry the dock once
// navigation settles, without recreating links or their dressing.
if(HasDockRoute)return;
if(Scene.NavMesh.IsGenerating){dockRetry=0;return;}
dockRetry+=RealTime.Delta;
if(dockRetry>=1){dockRetry=0;TryConnectDockRoute();}
return;
}
// The offline menu pauses Scene.TimeScale while it waits for these
// routes. Loading must use real time or Start can never become ready.
float setupDelta=RealTime.Delta;
wait+=setupDelta;
var expansion=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault();
if(wait<3 || Scene.NavMesh.IsGenerating || Scene.GetAllComponents<MapInstance>().Any(m=>m.Active && !m.IsLoaded)
|| (expansion.IsValid() && !expansion.Ready && string.IsNullOrEmpty(expansion.Error))){stableNav=0;return;}
stableNav+=setupDelta;if(stableNav<.7f)return;
Scan();Dress();Connect();
done=Routes.Count>0;
// A cloud/map rebuild may briefly leave no navigation points. Retry
// after settling instead of making that incomplete scan permanent.
if(!done){wait=0;stableNav=0;}
}
private bool Open(float y,float z)
=>!Scene.Trace.Ray(new Vector3(ScanFrom,y,z),new Vector3(ScanTo,y,z)).WithoutTags("paintball_actor","paintball_debris","overdrive_enemy").Run().Hit;
private void Scan()
{
doors.Clear();spans.Clear();Routes.Clear();
float start=float.NaN,last=0;
for(float y=700;y<=1960;y+=6)
{
bool open=Open(y,Floor+40) && Open(y,Floor+78);
if(open){if(float.IsNaN(start))start=y;last=y;}
if((!open || y+6>1960) && !float.IsNaN(start))
{
if(last-start>=30)
{
float mid=(start+last)/2,top=Floor+78;
while(top<Floor+200 && Open(mid,top+8))top+=8;
spans.Add((start,last,top));
var inside=Scene.NavMesh.GetClosestPoint(new Vector3(WallX-60,mid,Floor),80) ?? new Vector3(WallX-60,mid,Floor);
doors.Add(inside);
}
start=float.NaN;
}
}
Log.Info("KITCHEN_DOORWAYS count="+spans.Count+" spans="+string.Join(";",spans.Select(s=>$"y{s.Y0:0}-{s.Y1:0} top{s.Top:0}")));
}
private void Connect()
{
foreach(var span in spans)
{
float y=(span.Y0+span.Y1)*.5f;
var a=new Vector3(520,y,Floor+3);var b=new Vector3(400,y,Floor+3);
// Only bridge a real, full-height clear doorway, never a solid wall.
if(Scene.Trace.Capsule(new Capsule(Vector3.Up*17,Vector3.Up*57,17),a,b)
.WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit)continue;
var start=Scene.NavMesh.GetClosestPoint(a,35);var end=Scene.NavMesh.GetClosestPoint(b,35);
if(!start.HasValue || !end.HasValue)continue;
// Keep an explicit native connection across every verified opening.
// A path may be complete during startup, then become disconnected
// when the exit-only clip or later prop/nav rebuild finishes.
var go=new GameObject(dressing,false,"Prep kitchen doorway navigation"){NetworkMode=NetworkMode.Never};
var link=go.Components.Create<NavMeshLink>();link.WorldStartPosition=start.Value;link.WorldEndPosition=end.Value;
link.ConnectionRadius=30;link.IsBiDirectional=true;go.Enabled=true;
var spawn=Scene.NavMesh.GetClosestPoint(new Vector3(y<1000 ? 550 : 570,y,Floor+3),40);
if(spawn.HasValue)Routes.Add((spawn.Value,end.Value));
}
TryConnectDockRoute();
Log.Info($"KITCHEN_ENTRY_ROUTES {Routes.Count}");
}
public static string EntryName((Vector3 Spawn,Vector3 Exit) route)
=>route.Spawn.x>800 ? "Loading dock" : route.Exit.y<1000 ? "Restroom" : route.Exit.y>1600 ? "North kitchen" : "South kitchen";
public static IEnumerable<Vector3> ConcealedEntryCandidates((Vector3 Spawn,Vector3 Exit) route)
{
yield return route.Spawn;
// Stay in the assigned room. The old +/-175 offsets could select the
// neighbouring doorway's approach and silently feed the same entrance.
if(route.Exit.y<1000)
{
foreach(var offset in new[]{new Vector3(35,40,0),new Vector3(55,-35,0),new Vector3(85,65,0)})yield return route.Spawn+offset;
}
else if(route.Spawn.x>800)
{
foreach(var offset in new[]{Vector3.Left*100,Vector3.Right*100,Vector3.Left*175,Vector3.Right*175})yield return route.Spawn+offset;
}
else
{
foreach(var offset in new[]{new Vector3(-35,105,0),new Vector3(-35,-105,0),new Vector3(0,140,0),new Vector3(0,-140,0),
new Vector3(45,85,0),new Vector3(45,-85,0),new Vector3(105,125,0),new Vector3(105,-125,0)})yield return route.Spawn+offset;
// The north prep wall has a broad service window. Start behind the
// solid return, then walk up to the assigned north doorway.
if(route.Exit.y>1600)foreach(var offset in new[]{new Vector3(-35,-210,0),new Vector3(0,-235,0),new Vector3(45,-250,0)})yield return route.Spawn+offset;
}
}
public static bool UsesAssignedOpening(Sandbox.Navigation.NavMeshPath path,(Vector3 Spawn,Vector3 Exit) route)
{
if(path.Status.ToString()!="Complete")return false;
float wall=route.Spawn.x>800 ? 752 : WallX;
float center=route.Spawn.x>800 ? 1143 : route.Exit.y;
for(int i=1;i<path.Points.Count;i++)
{
var a=path.Points[i-1].Position;var b=path.Points[i].Position;
if(a.x>=wall && b.x<wall)
{
float y=a.y+(b.y-a.y)*((wall-a.x)/(b.x-a.x));
return MathF.Abs(y-center)<65;
}
}
return false;
}
private void TryConnectDockRoute()
{
if(HasDockRoute)return;
var expansion=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault();
if(expansion?.GeometryReady==true)
{
var dock=new Vector3(940,1295,34);var kitchen=new Vector3(650,1143,34);
var start=Scene.NavMesh.GetClosestPoint(dock,30);var end=Scene.NavMesh.GetClosestPoint(kitchen,30);
var from=new Vector3(700,1143,43);var to=new Vector3(810,1143,43);
bool clear=!Scene.Trace.Capsule(new Capsule(Vector3.Up*17,Vector3.Up*57,17),from,to)
.WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit;
if(clear && start.HasValue && end.HasValue && Scene.NavMesh.CalculatePath(new Sandbox.Navigation.CalculatePathRequest{Start=start.Value,Target=end.Value}).Status.ToString()=="Complete")
{
Routes.Add((start.Value,end.Value));
Log.Info("KITCHEN_DOCK_ROUTE ready");
}
}
}
// A painted frame, a warm lamp over each opening and a light spill, so
// both entrances read as doors from anywhere in the dining room.
private void Dress()
{
if(dressing.IsValid())dressing.Destroy();
dressing=new GameObject(true,"Kitchen doorway dressing"){NetworkMode=NetworkMode.Never};
if(!Components.Get<RestaurantBathroomEntrance>().IsValid())Components.Create<RestaurantBathroomEntrance>();
var box=Model.Load("models/dev/box.vmdl");var ball=Model.Load("models/dev/sphere.vmdl");
if(box.IsError)return;
var frame=new Color(.62f,.1f,.07f);
foreach(var s in spans)
{
if(s.Y1<1000)continue; // The outward-opening restroom door has its own signage.
float x=WallX-10,height=MathF.Min(s.Top,Floor+110)-Floor;
bool framed=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault()?.GeometryReady==true && s.Y0>1600;
if(!framed){
Piece(box,new Vector3(x,s.Y0-3,Floor+height/2),new Vector3(5,5,height),frame);
Piece(box,new Vector3(x,s.Y1+3,Floor+height/2),new Vector3(5,5,height),frame);
Piece(box,new Vector3(x,(s.Y0+s.Y1)/2,Floor+height+3),new Vector3(5,s.Y1-s.Y0+11,6),frame);
}
// Hazard stripe on the floor across the threshold.
Piece(box,new Vector3(x+4,(s.Y0+s.Y1)/2,Floor+.3f),new Vector3(10,s.Y1-s.Y0,.6f),new Color(1,.72f,.08f));
var lampAt=new Vector3(x-4,(s.Y0+s.Y1)/2,Floor+height+14);
// Light only: a bare sphere read as a floating ball.
var lightGo=new GameObject(dressing,true,"Kitchen door lamp light"){NetworkMode=NetworkMode.Never,WorldPosition=lampAt+Vector3.Backward*14};
var light=lightGo.Components.Create<PointLight>();light.Radius=260;light.LightColor=new Color(1,.6f,.3f)*2.2f;light.Shadows=false;
}
}
private GameObject Piece(Model model,Vector3 center,Vector3 size,Color tint)
{
var go=new GameObject(dressing,true,"Kitchen door frame piece"){NetworkMode=NetworkMode.Never};
var b=model.Bounds;
go.WorldScale=new Vector3(size.x/MathF.Max(.01f,b.Size.x),size.y/MathF.Max(.01f,b.Size.y),size.z/MathF.Max(.01f,b.Size.z));
var sc=go.WorldScale;go.WorldPosition=center-new Vector3(b.Center.x*sc.x,b.Center.y*sc.y,b.Center.z*sc.z);
var r=go.Components.Create<ModelRenderer>();r.Model=model;r.Tint=tint;
return go;
}
protected override void OnDestroy(){if(dressing.IsValid())dressing.Destroy();doors.Clear();spans.Clear();Routes.Clear();}
}