RestaurantArena.cs
using Sandbox;
using System;
using System.Threading.Tasks;
namespace PaintballBaddies;
/// <summary>Editable gameplay dressing over the original workshop restaurant.
/// Map and library assets mount by package identity on every machine.</summary>
public sealed class RestaurantArena : Component
{
public const string MapPackage="ethanthedoodler.los_hermanos";
public const string Plate="models/game_room/plate.vmdl";
public const string Shelf="models/unity stuff/1/shelf/shelf_b.vmdl";
public const string Fridge="models/newcliffy/fridge.vmdl";
public const string Table="models/props/trim_sheets/table/coffeetable_a.vmdl";
public const string Mug="models/props/mugs/mug_01.vmdl";
public const string CappuccinoCup="teacup/tea_cup.vmdl";
public const string Microwave="models/props/microwave/microwave.vmdl";
public const string Booth="models/newcliffy/booth.vmdl";
public const string Stove="models/electric_stove_static.vmdl";
public const string Sauce="models/saucebottle/saucebottle.vmdl";
public const string Chair="models/props/trim_sheets/chairs/wooden_chair_a.vmdl";
public const string Till="models/props/cash_register/cash_register.vmdl";
public const string Toaster="models/props/toaster/toaster.vmdl";
public const string BreakfastTray="models/restaurant/breakfast/steel_tray.vmdl";
public const string Breakfast="models/restaurant/breakfast/breakfast.vmdl";
public const string Stool="models/furniture/tavern_stool/tavern_stool.vmdl";
public const string CommercialCooker="models/rust_props/kitchen_furniture/kitchen_cooker.vmdl";
public const string KitchenPrep="models/rust_props/kitchen_furniture/kitchen_table.vmdl";
public const string KitchenSink="models/rust_props/kitchen_furniture/kitchen_sink.vmdl";
public const string Extractor="models/rust_props/kitchen_furniture/kitchen_extractor_hood.vmdl";
public const string CoffeeMachine="models/restaurant/breakfast/coffee_machine.vmdl";
public const string Griddle="models/restaurant/breakfast/griddle.vmdl";
[Property] public bool EnableServiceExpansion {get;set;}
public bool Ready {get;private set;}
public bool SourcePrepared=>prepared;
public string Error {get;private set;}="";
private bool dressing,prepared;
private int stockedRound=int.MinValue;
private float gibSweep;
private MapInstance map;
private GameObject decor;
private GameObject localMapRoot;
private readonly List<(string Kind,string Model,Vector3 Point,Rotation Rotation,float Scale)> stock=new();
private readonly List<(Model Model,Transform Pose)> chairs=new();
private readonly List<(Model Model,Transform Pose)> tables=new();
public const int TableShots=12;
protected override void OnStart()
{
if(EnableServiceExpansion && !Components.Get<RestaurantServiceExpansion>().IsValid())Components.Create<RestaurantServiceExpansion>();
if(!Components.Get<KitchenDoorways>().IsValid())Components.Create<KitchenDoorways>();
if(!Components.Get<RestaurantSiteProbe>().IsValid())Components.Create<RestaurantSiteProbe>();
if(!Components.Get<PoseSnapshots>().IsValid())Components.Create<PoseSnapshots>();
map=Scene.GetAllComponents<MapInstance>().FirstOrDefault();
// Late guests restore the host snapshot, which intentionally excludes
// the Never-networked source map. Load that source locally before the
// service replacement; the engine skips host-owned dynamic props on
// clients, while our breakable stock still arrives from the host.
if(!map.IsValid())
{
localMapRoot=new GameObject(false,"Local restaurant workshop source"){NetworkMode=NetworkMode.Never};
map=localMapRoot.Components.Create<MapInstance>();
map.MapName=MapPackage;map.UseMapFromLaunch=false;
localMapRoot.Enabled=true;
}
if(map.IsValid()){map.OnMapLoaded+=PrepareMap;if(map.IsLoaded)PrepareMap();}
_=Build();
}
private void PrepareMap()
{
if(prepared || !map.IsValid() || !map.IsLoaded)return;prepared=true;
// Old map physics furniture otherwise falls independently on each peer.
// Keep authored furniture stable, and use separate networked breakables.
// One-off inventory of the map's furniture, to find the dining tables.
Log.Info("RESTAURANT_PROPS "+string.Join(" ; ",map.GameObject.Components.GetAll<Prop>(FindMode.EverythingInSelfAndDescendants)
.GroupBy(p=>p.Model?.Name ?? "?").Select(g=>$"{g.Key} x{g.Count()} h{(g.First().Model?.Bounds.Size.z ?? 0)*g.First().WorldScale.z:0} w{(g.First().Model is {} m ? MathF.Max(m.Bounds.Size.x,m.Bounds.Size.y) : 0)*g.First().WorldScale.x:0}")));
Log.Info("RESTAURANT_MODELS "+string.Join(" ; ",map.GameObject.Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants)
.Where(r=>r.Model?.Name?.Contains("table",StringComparison.OrdinalIgnoreCase)==true)
.GroupBy(r=>r.Model.Name).Select(g=>$"{g.Key} x{g.Count()} prop={g.First().Components.Get<Prop>().IsValid()}")));
foreach(var prop in map.GameObject.Components.GetAll<Prop>(FindMode.EverythingInSelfAndDescendants).ToArray())
{
// The workshop restaurant contains over a hundred loose takeaway
// boxes. Remove this noisy physics clutter before rebuilding routes.
if(prop.Model?.Name=="megascans/debris/trash/burger_box_rmegu.vmdl"){prop.GameObject.Destroy();continue;}
if(prop.Model?.Name=="models/rust_props/wooden_furniture/chair_a.vmdl" && prop.WorldPosition.z>50){prop.GameObject.Destroy();continue;}
if(prop.Model?.Name?.Contains("chair",StringComparison.OrdinalIgnoreCase)==true)
{
chairs.Add((prop.Model,prop.WorldTransform));prop.GameObject.Destroy();continue;
}
// Dining tables become slow breakables: respawned each round and
// taking a dozen shots, so cover wears down instead of vanishing.
if(IsDiningTable(prop))
{
tables.Add((prop.Model,prop.WorldTransform));prop.GameObject.Destroy();continue;
}
prop.IsStatic=true;
}
foreach(var door in map.GameObject.Children.Where(g=>g.Name.StartsWith("ent_door")).ToArray())door.Enabled=false;
Log.Info($"RESTAURANT_TABLES count={tables.Count} models="+string.Join(",",tables.Select(t=>t.Model.Name).Distinct()));
Scene.NavMesh.SetDirty();
}
private static bool IsDiningTable(Prop prop)
{
var name=prop.Model?.Name ?? "";
if(!name.Contains("table",StringComparison.OrdinalIgnoreCase) || name.Contains("vegetable",StringComparison.OrdinalIgnoreCase)
|| name.Contains("tablet",StringComparison.OrdinalIgnoreCase) || name.Contains("coffeetable",StringComparison.OrdinalIgnoreCase))return false;
var size=prop.Model.Bounds.Size*prop.WorldScale.x;
return size.z>18 && size.z<70 && MathF.Max(size.x,size.y)>22;
}
private async Task Build()
{
if(dressing)return;dressing=true;
try
{
foreach(var ident in new[]{"caro.shelf_b","tones.fridge_industrial","facepunch.coffeetablea","facepunch.mug_01","ambi.plate","tones.booth_restaurant","baik.saucebottle","facepunch.wooden_chair_a","facepunch.cash_register","facepunch.toaster","baik.tavernstool","rust.kitchencooker","rust.kitchentable","rust.kitchensink","rust.kitchenextractorhood","titanovsky.tea_cup"})
await Cloud.Load<Model>(ident);
if(!IsValid || Scene!=Game.ActiveScene)return;
if(new[]{Shelf,Fridge,Table,Mug,Plate,Booth,Sauce,Chair,Till,Toaster,BreakfastTray,Breakfast,Stool,CommercialCooker,KitchenPrep,KitchenSink,Extractor,CoffeeMachine,Griddle,CappuccinoCup}.Any(p=>Model.Load(p).IsError))
throw new InvalidOperationException("A restaurant library model could not load");
decor=new GameObject(true,"Restaurant library dressing"){NetworkMode=NetworkMode.Never};
// Thin render-only finishes retain the workshop floor/wall collision,
// doorway widths and navigation. Four static meshes, no per-frame work.
foreach(var name in new[]{"dining_floor","kitchen_floor","calm_ceiling","kitchen_back_wall","diner_walls_lower","diner_walls_upper"})
{
var finish=new GameObject(decor,true,"Restaurant finish "+name){NetworkMode=NetworkMode.Never};
bool service=Components.Get<RestaurantServiceExpansion>()?.GeometryReady==true && (name=="kitchen_back_wall" || name.StartsWith("diner_walls_"));
finish.Components.Create<ModelRenderer>().Model=Model.Load($"models/restaurant/{(service ? "service" : "finish")}/{name}.vmdl");
}
Rack(new(-417,1120,32),0);
Rack(new(700,1860,32),180);
Rack(new(700,1000,32),180);
Fixture("Commercial upright refrigerator north",Fridge,new(689,1740,32),180,1,true);
Fixture("Commercial upright refrigerator south",Fridge,new(689,850,32),180,1,true);
CounterStock();
foreach(float y in new[]{1200f,1510f})Fixture("Restaurant upholstered booth",Booth,new(-401,y,32),0,.92f,false);
foreach(float y in new[]{1350f,1400f})
{
Fixture("Commercial kitchen range",CommercialCooker,new(709,y,32),180,1,false);
Fixture("Range extraction hood",Extractor,new(709,y,112),180,1.28f,false);
}
Fixture("Stainless preparation table",KitchenPrep,new(709,1490,32),180,1,false);
Fixture("Stainless plating table",KitchenPrep,new(709,1660,32),180,1,false);
Fixture("Central kitchen prep island",KitchenPrep,new(595,1240,32),180,1,false);
Fixture("Commercial wash sink",KitchenSink,new(709,1580,32),180,1,false);
Fixture("Separate kitchen rinse sink",KitchenSink,new(709,930,32),180,1,false);
Fixture("Commercial flat top griddle",Griddle,new(709,1400,70.2f),0,1,false);
// Broad, restrained work lights illuminate the equipment fronts.
// Local static dressing: no per-frame work or extra shadow maps.
foreach(float y in new[]{940f,1380f,1620f,1810f})
{
var lamp=new GameObject(decor,true,"Kitchen work lighting"){NetworkMode=NetworkMode.Never,WorldPosition=new Vector3(640,y,132)};
var light=lamp.Components.Create<PointLight>();light.LightColor=new Color(.88f,.94f,1)*.75f;light.Radius=205;light.Shadows=false;
}
// Condiment bottles now ride on the breakable dining tables (see stocking).
Fixture("The Missing Link diner sign","models/restaurant/identity/diner_sign.vmdl",new(-230,1898,110),-90,1,false);
Fixture("House specials menu","models/restaurant/identity/menu_board.vmdl",new(438,1100,136),180,1,false);
Fixture("Kitchen specials menu","models/restaurant/identity/menu_board.vmdl",new(438,1510,136),180,1,false);
// Sideboards sit against the north wall, clear of the counter aisle.
// Crockery is parented to each table so it cannot float after a break.
foreach(var pos in new[]{new Vector3(5,1902,32),new Vector3(125,1902,32)})
stock.Add(("wood",Table,pos,Rotation.FromYaw(90),1.65f));
Scene.NavMesh.SetDirty();Ready=true;
}
catch(Exception ex){Error="Restaurant dressing did not load. Restart to retry.";Log.Warning($"RESTAURANT_ASSETS {ex.Message}");}
}
private GameObject Fixture(string name,string path,Vector3 ground,float yaw,float scale,bool cover)
{
var model=Model.Load(path);var r=Rotation.FromYaw(yaw);
var go=new GameObject(decor,true,name){NetworkMode=NetworkMode.Never,WorldPosition=ground-r*(model.Bounds.Center.WithZ(model.Bounds.Mins.z)*scale),WorldRotation=r,WorldScale=new Vector3(scale)};
go.Components.Create<ModelRenderer>().Model=model;
var collider=go.Components.Create<ModelCollider>();collider.Model=model;collider.Static=true;
if(cover){var c=go.Components.Create<CoverSurface>();c.ModelHeight=model.Bounds.Maxs.z;c.StandingOnly=true;}
return go;
}
private void Rack(Vector3 ground,float yaw)
{
const float scale=1.1f;var r=Rotation.FromYaw(yaw);
Fixture("Crockery rack",Shelf,ground,yaw,scale,false);
// Upright plates are large enough to read and aim at across the room.
// Their ceramic fronts face out of the shelves, not into the wall.
foreach(float height in new[]{38f,72f})
foreach(float offset in new[]{-17f,0,17})
stock.Add(("plate",Plate,ground+r*new Vector3(5,offset,height+6),r*Rotation.FromPitch(78),.82f));
foreach(float offset in new[]{-16f,0,16})
stock.Add(("mug",Mug,ground+r*new Vector3(3,offset,102.5f),r,1.5f));
}
private void CounterStock()
{
// All countertop dressing is round stock, with its own weapon collider
// and multi-hit damage. Stacks share one parent so no upper dishes float.
stock.Add(("counter_metal",Till,new(236,1740,63.2f),Rotation.FromYaw(0),2.25f));
stock.Add(("counter_metal",Toaster,new(238,1604,63.2f),Rotation.FromYaw(90),1));
stock.Add(("counter_food",BreakfastTray,new(442,1640,82.3f),Rotation.FromYaw(180),1));
stock.Add(("counter_metal",CoffeeMachine,new(392,1270,63.2f),Rotation.Identity,1.3f));
foreach(var point in new[]{new Vector3(420,1259,63.2f),new Vector3(420,1279,63.2f),new Vector3(236,1566,63.2f)})
stock.Add(("counter_cup_stack",Plate,point,Rotation.Identity,.46f));
foreach(var point in new[]{new Vector3(706,1467,68.05f),new Vector3(706,1511,68.05f),new Vector3(706,1640,68.05f),new Vector3(595,1230,68.05f)})
stock.Add(("counter_plate_stack",Plate,point,Rotation.Identity,.78f));
foreach(float y in new[]{1290f,1430f,1570f,1700f})
{
stock.Add(("counter_plate",Plate,new(199,y,63.2f),Rotation.Identity,.82f));
stock.Add(("counter_mug",Mug,new(224,y+20,63.2f),Rotation.FromYaw(y%80-40),1.2f));
stock.Add(("counter_glass",Sauce,new(251,y-5,63.2f),Rotation.FromYaw(180),.72f));
}
foreach(float y in new[]{1637f,1652f})
stock.Add(("counter_mug",Mug,new(239,y,63.2f),Rotation.FromYaw(110),1.2f));
// Full-width, 22-inch seats suit this 31-inch service counter. Gaps
// preserve the room's cover access and the kitchen-door approach.
foreach(float y in new[]{1280f,1360f,1440f,1520f,1700f,1780f})
stock.Add(("stool",Stool,new(148,y,32.2f),Rotation.FromYaw(180),1.10f));
}
protected override void OnUpdate()
{
if(!prepared && map?.IsLoaded==true)PrepareMap();
gibSweep-=Time.Delta;
if(gibSweep<=0)
{
gibSweep=.4f;
// Native remote gib creation does not raise the source Prop callback.
// Apply the same short debris lifetime on both peers.
foreach(var gib in Scene.GetAllComponents<Gib>())
{
string name=gib.Components.Get<ModelRenderer>()?.Model?.Name ?? "";
if((name.Contains("chair",StringComparison.OrdinalIgnoreCase) || name.StartsWith("models/props/mugs/mug_01_gib") || name.StartsWith("models/props/trim_sheets/table/coffeetable")
|| name.StartsWith("models/props/cash_register/") || name.StartsWith("models/props/toaster/") || name.StartsWith("models/items/plain_set/") || name.StartsWith("models/saucebottle/")) && !gib.Components.Get<RestaurantGibLifetime>().IsValid())
{gib.GameObject.Tags.Add("paintball_debris");gib.Components.Create<RestaurantGibLifetime>();}
}
}
var s=MultiplayerSession.Find(Scene);
if(!Ready || !prepared || s is null || !MultiplayerSession.Authority || stockedRound==s.RoundId)return;
stockedRound=s.RoundId;
foreach(var old in Scene.GetAllComponents<RestaurantBreakable>().ToArray())old.GameObject.Destroy();
foreach(var item in stock)
{
var model=Model.Load(item.Model);var offset=item.Kind=="plate" ? model.Bounds.Center : model.Bounds.Center.WithZ(model.Bounds.Mins.z);
var go=new GameObject(false,"Restaurant "+item.Kind){WorldPosition=item.Point-item.Rotation*(offset*item.Scale),WorldRotation=item.Rotation,WorldScale=new Vector3(item.Scale)};
var prop=go.Components.Create<Prop>();prop.Model=model;prop.IsStatic=true;
var b=go.Components.Create<RestaurantBreakable>();b.Kind=item.Kind;b.Round=s.RoundId;
DressStack(go,item.Kind,model,item.Scale);
if(item.Kind=="wood")
{
foreach(float dishOffset in new[]{-23f,0,23})
{
var dishes=new GameObject(go,true,"Sideboard crockery")
{WorldPosition=item.Point+new Vector3(dishOffset,0,model.Bounds.Size.z*item.Scale+.15f),WorldRotation=Rotation.Identity,WorldScale=new Vector3(.78f)};
var dishProp=dishes.Components.Create<Prop>();dishProp.Model=Model.Load(Plate);dishProp.IsStatic=true;
var dishBreak=dishes.Components.Create<RestaurantBreakable>();dishBreak.Kind="counter_plate_stack";dishBreak.Round=s.RoundId;
DressStack(dishes,dishBreak.Kind,dishProp.Model,.78f);
}
}
if(item.Kind=="counter_food")
{
// The whole plated serving shares one damage state. Food collision
// resolves to this parent, and no topping survives a broken tray.
var food=new GameObject(go,true,"Hash browns and fried eggs")
{LocalPosition=model.Bounds.Center.WithZ(model.Bounds.Mins.z+.45f),LocalScale=new Vector3(1/item.Scale)};
var foodModel=Model.Load(Breakfast);food.Components.Create<ModelRenderer>().Model=foodModel;
var collider=food.Components.Create<ModelCollider>();collider.Model=foodModel;collider.Static=true;
}
go.Enabled=true;
if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
}
foreach(var table in tables)
{
var go=new GameObject(false,"Restaurant breakable table"){WorldPosition=table.Pose.Position,WorldRotation=table.Pose.Rotation,WorldScale=table.Pose.Scale};
var prop=go.Components.Create<Prop>();prop.Model=table.Model;prop.IsStatic=true;
var b=go.Components.Create<RestaurantBreakable>();b.Kind="table";b.Round=s.RoundId;
// A condiment bottle on top that goes down with the table.
var sauce=Model.Load(Sauce);
if(!sauce.IsError)
{
var top=table.Model.Bounds;var bottle=new GameObject(go,true,"Table condiment bottle")
{LocalPosition=new Vector3(top.Center.x,top.Center.y,top.Maxs.z)-sauce.Bounds.Center.WithZ(sauce.Bounds.Mins.z)*(.65f/MathF.Max(.01f,table.Pose.Scale.x)),
LocalRotation=Rotation.FromYaw(15),LocalScale=new Vector3(.65f/MathF.Max(.01f,table.Pose.Scale.x))};
bottle.Components.Create<ModelRenderer>().Model=sauce;
}
go.Enabled=true;
if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
}
foreach(var chair in chairs)
{
// Matching library chairs replace the map's mismatched old seating.
// BreakCue reuses existing wooden fragment meshes on every peer.
var go=new GameObject(false,"Restaurant breakable chair"){WorldPosition=chair.Pose.Position,WorldRotation=chair.Pose.Rotation};
var prop=go.Components.Create<Prop>();prop.Model=Model.Load(Chair);prop.IsStatic=true;
var b=go.Components.Create<RestaurantBreakable>();b.Kind="chair";b.Round=s.RoundId;go.Enabled=true;
if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
}
}
private static void DressStack(GameObject parent,string kind,Model plate,float scale)
{
if(kind=="counter_plate_stack")
{
for(int layer=1;layer<6;layer++)
{
var dish=new GameObject(parent,true,"Stacked ceramic plate"){LocalPosition=Vector3.Up*(layer*1.15f),LocalRotation=Rotation.FromYaw(layer*17)};
dish.Components.Create<ModelRenderer>().Model=plate;
var collider=dish.Components.Create<ModelCollider>();collider.Model=plate;collider.Static=true;
}
}
if(kind=="counter_cup_stack")
{
var cup=Model.Load(CappuccinoCup);
for(int layer=0;layer<3;layer++)
{
var piece=new GameObject(parent,true,"Stacked cappuccino cup")
{LocalPosition=Vector3.Up*(.6f+layer*1.65f)/scale,LocalRotation=Rotation.FromYaw(layer*34),LocalScale=new Vector3(1.4f,1.4f,.85f)/scale};
var render=piece.Components.Create<ModelRenderer>();render.Model=cup;render.MaterialOverride=Material.Load("models/restaurant/breakfast/porcelain.vmat");
var collider=piece.Components.Create<ModelCollider>();collider.Model=cup;collider.Static=true;
}
}
}
protected override void OnDestroy()
{
if(map.IsValid())map.OnMapLoaded-=PrepareMap;
if(decor.IsValid())decor.Destroy();
if(localMapRoot.IsValid())localMapRoot.Destroy();
}
}
/// <summary>Native Prop owns collision, health, gibs and replicated destruction.
/// This bridge validates host weapon damage and supplies the missing plate FX.</summary>
public sealed class RestaurantBreakable : Component
{
[Sync(SyncFlags.FromHost)] public string Kind {get;set;}="plate";
[Sync(SyncFlags.FromHost)] public int Round {get;set;}
public static int Broken {get;private set;}
public static int NativeGibs {get;private set;}
private bool broken;
public bool BrokenState=>broken;
// Tables (dining and coffee) break after a dozen hits, not one blast.
private bool Slow=>Kind is "table" or "wood";
public bool CounterItem=>Kind.StartsWith("counter_",StringComparison.Ordinal);
public int ShotsRequired=>Slow ? RestaurantArena.TableShots : Kind=="counter_food" ? 1 : CounterItem || Kind is "chair" or "stool" ? 4 : 0;
[Sync(SyncFlags.FromHost)] public int ShotsTaken {get;private set;}
protected override void OnStart()
{
var prop=Components.Get<Prop>();
if(!prop.IsValid())return;
if(MultiplayerSession.Authority)prop.Health=ShotsRequired>0 ? 1000 : Kind=="chair" ? 24 : 8;
prop.OnGibsCreated+=gibs=>
{
NativeGibs+=gibs.Count;
foreach(var gib in gibs)if(gib.IsValid()){gib.GameObject.Tags.Add("paintball_debris");gib.Components.Create<RestaurantGibLifetime>();}
};
}
public static RestaurantBreakable Find(GameObject go)
{
while(go.IsValid()){var b=go.Components.Get<RestaurantBreakable>();if(b.IsValid())return b;go=go.Parent;}return null;
}
public void Hit(float damage,Vector3 point,PaintballCombatant attacker)
{
var s=MultiplayerSession.Find(Scene);var prop=Components.Get<Prop>();
if(!MultiplayerSession.Authority || broken || !prop.IsValid() || s?.IsOverdrive!=true || !s.InRound || Round!=s.RoundId || !attacker.IsValid() || !attacker.AcceptHits || !float.IsFinite(damage) || damage<=0)return;
if(ShotsRequired>0)
{
ShotsTaken++;
if(ShotsTaken<ShotsRequired)
{
if(CounterItem)CounterWearCue(point,Kind,Round);
else WearCue(GameObject,point,ShotsTaken,Round);
return;
}
damage=prop.Health+1;
}
if(damage>=prop.Health)
{
broken=true;Broken++;BreakCue(point,Kind,Round,WorldPosition,WorldRotation,(point-attacker.WorldPosition).WithZ(0).Normal);
if(Slow || Kind is "chair" or "stool")Scene.NavMesh.SetDirty();
}
prop.OnDamage(new DamageInfo{Damage=damage,Attacker=attacker.GameObject,Origin=attacker.WorldPosition+Vector3.Up*55,Position=point});
// Some library appliances have no Prop break data. Keep disappearance
// authoritative while BreakCue supplies bounded material-specific debris.
if(broken && (CounterItem || Kind is "chair" or "stool") && GameObject.IsValid())GameObject.Destroy();
}
protected override void OnUpdate()
{
if(!CounterItem && Kind is not ("chair" or "stool"))return;
// Synced hit count also restores wear for a late joiner; RPC-only tint
// would incorrectly show a pristine prop on that client.
foreach(var render in Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants))
render.Tint=Color.Lerp(Color.White,new Color(.55f,.51f,.46f),ShotsTaken/(float)ShotsRequired);
}
[Rpc.Broadcast(NetFlags.HostOnly)] private static async void CounterWearCue(Vector3 point,string kind,int round)
{
var scene=Game.ActiveScene;await GameTask.MainThread();
if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
CounterDebris.Spawn(scene,point,kind,round,false);
}
// Each hit on a table: a crack, a few splinters, and the wood darkens and
// scuffs so the damage (and how close it is to going) reads at a glance.
[Rpc.Broadcast(NetFlags.HostOnly)] private static async void WearCue(GameObject table,Vector3 point,int shots,int round)
{
var scene=Game.ActiveScene;await GameTask.MainThread();
if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
FeelAudio.Play("wood_crack",3,point,.55f+.3f*shots/RestaurantArena.TableShots,900,.1f);
RestaurantChairDebris.Splinters(scene,point,2,round);
if(!table.IsValid())return;
float wear=(float)shots/RestaurantArena.TableShots;
foreach(var r in table.Components.GetAll<ModelRenderer>(FindMode.EverythingInSelf))r.Tint=Color.Lerp(Color.White,new Color(.45f,.36f,.3f),wear);
}
[Rpc.Broadcast(NetFlags.HostOnly)] private static async void BreakCue(Vector3 point,string kind,int round,Vector3 origin,Rotation rotation,Vector3 direction)
{
var scene=Game.ActiveScene;await GameTask.MainThread();
if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
if(kind=="plate")SausageCeramicBurst.Spawn(point);
if(kind is "chair" or "stool")RestaurantChairDebris.Spawn(scene,origin,rotation,round);
if(kind is "table" or "wood")
{
RestaurantChairDebris.Spawn(scene,origin+Vector3.Up*10,rotation,round,1.6f);
FeelAudio.Play("wood_crack",3,point,1,1100,.08f);
}
if(kind is "plate" or "mug")SausageKitchenAudio.Play(true,point);
if(kind.StartsWith("counter_",StringComparison.Ordinal))CounterDebris.Spawn(scene,point,kind,round,true);
if(kind=="counter_food")CounterTrayFlight.Spawn(scene,origin,rotation,direction,round);
// Native table and mug models supply their own matching gib shapes.
}
}
public sealed class RestaurantGibLifetime : Component
{
private float age;
protected override void OnUpdate(){age+=Time.Delta;if(age>4)GameObject.Destroy();}
}