A game component representing a boarded window (barricade). It tracks plank count, builds a physical collider and visual boards, publishes a NavMesh link for zombies to path through, handles tearing by zombies and repairing by players, and synchronises plank counts across host and clients.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// BARRICADE — a boarded window. Zombies tear through it; the player cannot.
///
/// Ported from the original's `breakable_entry` entity
/// (entities/entities/breakable_entry/shared.lua) and the `nzMapping:BreakEntry`
/// spawner it is built by (mapping/sv_mapping.lua:977).
///
/// The numbers are its own:
/// 6 planks `self:GetNumPlanks() < 6`, throughout
/// 10 points a board `ply:GivePoints(10, false, true)` in ENT:Use
/// ~1s a board `local time = oncrack and 0.5 or 1` — Speed Cola halves it
///
/// ⛔ PLAYER COLLISION IS AN OPTION IN THE ORIGINAL, AND IT DEFAULTS TO OFF.
/// `BreakEntry`'s `plycollision` defaults false, and its collision hook reads:
///
/// if ent1:GetClass() == "breakable_entry" and ent2:IsPlayer()
/// and !ent1:GetPlayerCollision() then return false end
///
/// — i.e. by default players walk straight through. We default it ON, because a
/// window the player can stroll through is not a barricade. That is a
/// DELIBERATE divergence, not a porting slip.
///
/// ⚠️ HOW "ZOMBIES PASS, PLAYERS DO NOT" IS ACHIEVED HERE: a physics collider
/// blocks the player, and zombies do not care because they move on a NAVMESH
/// AGENT — agents are steered by the navmesh, which was generated without this
/// runtime object in it. So the same collider that stops a player is invisible
/// to a zombie's locomotion. No collision-matrix surgery, no per-frame filtering.
/// </summary>
public sealed partial class Barricade : Component
{
/// <summary>The original's cap, hardcoded in every plank check it makes.</summary>
public const int MaxPlanks = 6;
/// <summary>Boards currently in place. 0 means the window is open.</summary>
[Property, ReadOnly] public int Planks { get; private set; } = MaxPlanks;
/// <summary>Points for repairing one board. `GivePoints(10, …)` in ENT:Use.</summary>
[Property] public int RepairPoints { get; set; } = 10;
/// <summary>
/// Seconds between boards while repairing. The original's `time`.
///
/// ⛔ SPEED COLA DELIBERATELY DOES NOT SCALE THIS — a scope decision, not an
/// oversight, so nobody "fixes" it. The original halves repair time with the perk
/// (x0.5) and PERK_BASE_EFFECTS.md lists it, but repair is out of Speed Cola's
/// base effect here for now; the perk is reloads only.
///
/// It was briefly wired and reverted on 2026-08-19. The wording before that said
/// "neither perk exists here yet", which quietly became false once Speed Cola was
/// built and read as an unfinished job rather than a choice.
///
/// ⚠️ The Amish upgrade (0.4/0.75) does not exist either.
/// </summary>
[Property] public float RepairInterval { get; set; } = 1f;
/// <summary>
/// Should this one stop the player?
///
/// ⚠️ Ours defaults TRUE — see the class remarks. Kept as a property rather
/// than a constant because the original exposes it per-barricade and a mapper
/// may want a decorative one.
/// </summary>
[Property] public bool BlocksPlayer { get; set; } = true;
/// <summary>
/// The volume: x = LENGTH along the run, y = THICKNESS, z = HEIGHT.
///
/// ⛔ X IS THE LENGTH, because Rotation.From(0, yaw, 0) points the object's
/// FORWARD (+x) along the yaw — so the run has to live on x. Putting length on
/// y built every barricade at ninety degrees to the two points that defined
/// it, which looks like a placement bug and is actually an axis one.
/// </summary>
[Property] public Vector3 Size { get; set; } = new( 96f, 6f, 44f );
/// <summary>
/// Surface. Wood, and deliberately NOT the concrete a debris block uses — the
/// two are both grey slabs at a distance otherwise, and one is buyable while
/// the other is permanent.
///
/// ⚠️ Named MaterialPath, not Material: a property called `Material` SHADOWS
/// the Material TYPE inside this class, so `Material.Load(...)` would try to
/// resolve against the string instead of the type.
/// </summary>
[Property] public string MaterialPath { get; set; } = "materials/models/cscgroupe/props/awp_ardennes/wood_trunk01.vmat";
/// <summary>Tint over the material. White leaves it alone.</summary>
[Property] public Color Tint { get; set; } = Color.White;
/// <summary>Board depth as a fraction of the wall's thickness.
///
/// ⚠️ A FRACTION, not an absolute. Thickness is author-set per barricade, and
/// a fixed depth would read correctly on a 6-thick wall and absurd on a
/// 24-thick one. At 0.5 a board is half the depth of what it is nailed to,
/// which is what makes it read as a plank rather than a beam.</summary>
[Property] public float BoardThickness { get; set; } = 0.5f;
/// <summary>
/// The run in world space, set by the manager from the two clicked points.
///
/// ⛔ KEPT because distance-to-CENTRE is useless for a long barricade: a
/// zombie arriving near either END is far from the midpoint and would never
/// trigger. Everything that asks "am I at this barricade" must measure
/// against the SEGMENT.
/// </summary>
public Vector3 RunA { get; set; }
public Vector3 RunB { get; set; }
/// <summary>
/// Shortest distance from a point to the run, ignoring height.
///
/// ⚠️ Flattened. A zombie stands on the floor and the run is at the sill, so
/// a 3D distance would add the sill height to every measurement and make the
/// reach behave differently for a tall barricade than a short one.
/// </summary>
public float DistanceToRun( Vector3 point )
{
var a = RunA.WithZ( 0 );
var b = RunB.WithZ( 0 );
var p = point.WithZ( 0 );
var ab = b - a;
float len2 = ab.LengthSquared;
// Degenerate run — fall back to the origin rather than dividing by zero.
if ( len2 < 0.01f ) return p.Distance( WorldPosition.WithZ( 0 ) );
float t = Math.Clamp( Vector3.Dot( p - a, ab ) / len2, 0f, 1f );
return p.Distance( a + ab * t );
}
// ── crossing ─────────────────────────────────────────────────────────────
/// <summary>
/// Area definition priced for going through a window. Tunable with `nz_nav_cost`.
/// </summary>
public const string CrossArea = "nav/barricade.navarea";
/// <summary>
/// How far out from the run each landing point sits.
///
/// ⚠️ MUST STAY INSIDE BarricadeReach (38u), or a zombie that walks to the landing point is
/// too far from the run to tear the boards and stands there instead. It also has to clear the
/// collider — half of Thickness (3u by default) plus an agent radius (9u) — so the usable band
/// is roughly 14..38 and 34 sits near the top of it, as far from the boards as it can be while
/// still being able to reach them.
/// </summary>
[Property] public float CrossOffset { get; set; } = 34f;
/// <summary>
/// The two fixed landing points, one either side of the run.
///
/// ⛔ THE WHOLE POINT: A CROSSING BELONGS TO THE BARRICADE, NOT TO WHOEVER IS CROSSING IT. The
/// previous version computed the landing as `zombie position + 70u toward the player`, so it
/// depended on where the zombie happened to stand and where the player happened to be — and
/// nothing checked the result against geometry. Approach at an angle and it landed inside a
/// wall. Two points derived from the run and snapped to the navmesh once cannot do that.
///
/// ⚠️ TWO POINTS, NOT ONE "INSIDE". Deriving which side is the interior needs a rule that does
/// not exist for a barricade standing in the open, and picking wrong would send zombies out of
/// the map. One fixed point PER SIDE gives the same guarantee — a crossing always ends in the
/// same place — without having to answer a question the geometry cannot.
/// </summary>
public Vector3 CrossA { get; private set; }
public Vector3 CrossB { get; private set; }
/// <summary>Both landing points are on the navmesh and on opposite sides of the run.</summary>
public bool CrossValid { get; private set; }
/// <summary>The run's normal — the axis the crossing runs along.
///
/// ⛔ THE OBJECT'S OWN Y AXIS, WHICH IS WHY NO GUESSING IS NEEDED. BarricadeManager builds the
/// object with `Rotation.From( 0, yaw, 0 )` and `Size = (Length, Thickness, Height)`, so +x is
/// the run and ±y is across it. The crossing direction is therefore already a fact about the
/// transform rather than something to infer from the player's position.</summary>
public Vector3 RunNormal => WorldRotation.Left;
/// <summary>
/// Which side of the run a point is on. Sign only; 0 means on the line.
///
/// ⚠️ MOVED HERE FROM ZombieAI, where it was a private helper. The barricade is what defines
/// its own sides, so anything asking the question should ask the barricade.
/// </summary>
public float SideOf( Vector3 point )
{
var run = (RunB - RunA).WithZ( 0 );
if ( run.LengthSquared < 1f ) return 0f;
var d = (point - RunA).WithZ( 0 );
return run.x * d.y - run.y * d.x;
}
/// <summary>Are these two points on the same side of the run?</summary>
public bool SameSide( Vector3 a, Vector3 b )
{
float x = SideOf( a ), y = SideOf( b );
return x * y > 0f;
}
/// <summary>
/// The fixed landing point for something crossing FROM this position.
///
/// ⚠️ Chosen by which side the crosser is on — never by where its target is. That is the whole
/// correction: a barricade knows where its two sides are, and the player's position has nothing
/// to do with where a crossing ends up.
/// </summary>
public Vector3 LandingFor( Vector3 from )
{
// ⛔ THE FARTHER POINT, NOT THE SIDE TEST — AND THIS IS THE PING-PONG FIX. The side test
// version read `SameSide( from, CrossA ) ? CrossB : CrossA`, and SameSide is a SIGN PRODUCT:
// it returns false for a point ON the run, because one factor is ~0. So a zombie entering the
// crossing at the sill fell through to `CrossA` — which is frequently the side it had just
// come from. It landed where it started, was immediately eligible again, and crossed forever.
//
// Distance cannot do that. Whichever endpoint is farther is the one across the run, and at
// worst — a zombie exactly on the sill, equidistant — it picks one and commits, rather than
// systematically picking the near one.
//
// ⚠️ The side test is still the right question in the abstract; it is just not ROBUST at the
// one position where the answer matters most. SameSide stays for callers that want the
// honest predicate.
return from.Distance( CrossA ) > from.Distance( CrossB ) ? CrossA : CrossB;
}
/// <summary>
/// Work out both landing points and snap them to the navmesh.
///
/// ⚠️ TRACE FOR THE FLOOR, THEN SNAP — the same two steps NavLinkManager.BuildDrop uses, and for
/// the same reason: the object's origin is at the SILL, so a point offset sideways from it hangs
/// at sill height rather than standing on the floor, and Recast insets the walkable surface from
/// every ledge and wall so an untraced point routinely sits just off the mesh.
///
/// ⛔ REJECTS A SNAP THAT CROSSES THE RUN. GetClosestPoint returns the nearest mesh point in ANY
/// direction, so a landing point in a tight window can snap back through the wall and end up on
/// the side it started. Two "landing points" on the same side is a crossing that goes nowhere,
/// and it would look like a working link.
/// </summary>
public bool BuildCrossing()
{
CrossValid = false;
var scene = Scene;
var nav = scene?.NavMesh;
if ( nav is null || !nav.IsEnabled ) return false;
var run = (RunB - RunA).WithZ( 0 );
if ( run.LengthSquared < 1f ) return false;
var n = RunNormal.WithZ( 0 ).Normal;
if ( n.LengthSquared < 0.01f ) return false;
var mid = (RunA + RunB) * 0.5f;
var a = Snap( scene, nav, mid + n * CrossOffset );
var b = Snap( scene, nav, mid - n * CrossOffset );
if ( a is null || b is null ) return false;
// ⛔ The sides must still be opposite AFTER snapping. See the remarks.
if ( SameSide( a.Value, b.Value ) ) return false;
CrossA = a.Value;
CrossB = b.Value;
CrossValid = true;
return true;
}
/// <summary>Floor under a point, then the navmesh on that floor's level: never a level overhead, null when there is none.</summary>
static Vector3? Snap( Scene scene, Sandbox.Navigation.NavMesh nav, Vector3 at )
{
// From above, so a probe starting inside the sill still reads the floor below it.
// ⚠️ THROUGH BODIES (`StandIgnores`): a rebuild with a zombie or a player standing on the point read the top of their head.
var tr = scene.Trace.Ray( at + Vector3.Up * 40f, at + Vector3.Down * 512f ).WithoutTags( StandIgnores ).Run();
var ground = tr.Hit ? tr.HitPosition : at;
// ⛔ ON THE FLOOR'S OWN LEVEL, NEVER OVERHEAD (2026-10-05). This was `GetClosestPoint( ground )`, the nearest mesh in any
// direction. In a spawn closet the navmesh doesn't reach, that is the closet's own ROOF, 96 units up, on the closet's
// side of the run, so the crossing passed the same-side test with one end on the roof. `SpawnSideFor` then spawned the
// zombie there, and the window's nav link joined the room to it. Defocus, every game: zombies standing on the
// roofs over spawns #0, #3, #4, #8, #9 and #16, stuck, 717 relocations in one night (the user: *"zombies spawning above
// where they should"*). A closet with no mesh now has no crossing, and its zombies are held at the window
// (`ZombieAI.ParkedAt`) until they climb through, as the other 16 windows' already were.
var level = nav.GetClosestPoint( new BBox( ground - new Vector3( SnapSide, SnapSide, SnapStep ), ground + new Vector3( SnapSide, SnapSide, SnapStep ) ) );
if ( level.HasValue ) return level;
// ⚠️ THE OLD ANSWER STILL COUNTS WHEN IT ISN'T OVERHEAD (a floor the box misses, a lower step), as `ZombieAI.NavGround` keeps it
var any = nav.GetClosestPoint( ground );
return any.HasValue && any.Value.z <= ground.z + SnapStep ? any : null;
}
/// <summary>How far to the side and up or down a crossing point may sit from the floor under its probe (`Snap`).</summary>
const float SnapSide = 48f, SnapStep = 24f;
/// <summary>
/// How close an agent has to be for the link to pick it up.
///
/// ⛔ MUST STAY WELL UNDER CrossOffset, AND DID NOT. This shipped at 40 against a CrossOffset of
/// 34, so the pickup sphere around each endpoint reached 6 units PAST the run and onto the far
/// side. An agent could enter the crossing while standing on the sill — or already across it —
/// where "which side am I on" has no clean answer. That is half of the ping-pong bug; the other
/// half was LandingFor resolving that ambiguity the wrong way.
///
/// ⚠️ Clamped in BuildNavLink rather than trusted, because it is a [Property] and the next person
/// to raise it while tuning would silently reintroduce the loop.
/// </summary>
[Property] public float CrossConnectionRadius { get; set; } = 20f;
GameObject _navLink;
/// <summary>The link is standing and the pathfinder can see this window.</summary>
public bool NavLinked => _navLink.IsValid();
/// <summary>
/// Publish the crossing as a NavMeshLink, so the PATHFINDER knows a window is a way through.
///
/// ⛔ THIS IS THE ANSWER TO "ZOMBIES DO NOT UNDERSTAND THAT CROSSING CAN REACH ME". A barricade
/// builds a solid BoxCollider, and nothing excludes it from generation, so the navmesh is CARVED
/// at every window: there was no edge in the graph joining the two sides. A route through a
/// window therefore did not exist as far as the pathfinder was concerned, and zombies only ever
/// arrived at one by accident — walking toward an unreachable player and stopping at the nearest
/// reachable point. Which side they stopped on, and whether that was the window at all, was
/// down to where the geometry happened to put it.
///
/// With the link in place the window is a real edge with a real price, so "go through the
/// window" competes with "walk round through the door" on cost and the pathfinder picks. That is
/// the same mechanism the jump/drop links use, and the same one `nz_nav_cost` tunes.
///
/// ⚠️ BUILT WHETHER THE BOARDS ARE UP OR NOT, deliberately. Gating the link on IsOpen would
/// remove the window from the graph exactly when zombies most need to be routed to it — nobody
/// would ever come to tear the boards down. The boards are a DELAY on traversal, enforced when
/// an agent arrives, not an absence of the route.
///
/// ⚠️ BI-DIRECTIONAL, unlike the jump/drop links. Those must be one-way because the vertical
/// gate works by forbidding an area and an area has no direction. A barricade has no such gate
/// and genuinely works both ways, so one link is right.
/// </summary>
public bool BuildNavLink()
{
ClearNavLink();
if ( !CrossValid && !BuildCrossing() ) return false;
var go = Scene.CreateObject();
go.Name = "Barricade crossing";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
go.WorldPosition = CrossA;
go.WorldRotation = Rotation.Identity;
var link = go.Components.Create<Sandbox.NavMeshLink>();
link.LocalStartPosition = Vector3.Zero;
link.LocalEndPosition = CrossB - CrossA;
// ⚠️ Fields, not properties — invisible in the inspector and absent from the scene file, so
// assigning them here is the only place they are ever set. Same note as NavLinkManager.Build.
link.IsBiDirectional = true;
// ⛔ CLAMPED BELOW CrossOffset. A pickup radius that reaches past the run lets an agent enter
// the crossing from the wrong side of it — see CrossConnectionRadius. 0.6 leaves a clear
// margin either side at the default 34u offset.
link.ConnectionRadius = MathF.Max( 8f,
MathF.Min( CrossConnectionRadius, CrossOffset * 0.6f ) );
var area = NavLinkManager.Area( CrossArea );
if ( area is not null ) link.Area = area;
else Log.Warning( $"[nz-barr] area '{CrossArea}' missing — this crossing cannot be priced"
+ " and will cost the same as flat ground" );
link.LinkEntered += OnAgentEnteredCrossing;
_navLink = go;
return true;
}
public void ClearNavLink()
{
_navLink?.Destroy();
_navLink = null;
}
/// <summary>
/// An agent has reached the crossing.
///
/// ⛔ THE BOARDS ARE CHECKED HERE, NOT BY THE LINK'S EXISTENCE. A zombie that arrives at a
/// boarded window must stay put and tear, which is what the reach-based tear logic in ZombieAI
/// already does — so this simply refuses the crossing and lets that take over.
/// </summary>
void OnAgentEnteredCrossing( Sandbox.NavMeshAgent agent )
{
if ( !agent.IsValid() ) return;
// ⛔ THE BOARDS ARE NO LONGER TESTED HERE, AND MOVING THAT TEST IS A BUG FIX. This read
// `if ( !agent.IsValid() || !IsOpen ) return;` — so a zombie arriving at a BOARDED window
// returned before ever reaching BeginBarricadeCross, which is the only place that cancels a
// traversal. With AutoTraverseLinks off the agent is already parked in the link by the time
// this runs and waits to be released, so that early return left it there permanently: stuck
// behind the barricade, never crossing, never tearing, never walking away.
//
// ⚠️ REFUSING AND IGNORING ARE NOT THE SAME THING, which is the general lesson. Every path
// that declines a crossing must say so to the agent; a `return` says nothing. So the barricade
// now reports the arrival unconditionally and the zombie decides — it is the only side that
// can act on the answer.
var z = agent.Components.Get<ZombieAI>( FindMode.EverythingInSelf );
if ( z.IsValid() ) z.BeginBarricadeCross( this );
}
/// <summary>How close the player must stand to rebuild. Generous — you repair
/// by BEING there, not by aiming, so the volume has to forgive a step.</summary>
[Property] public float RepairReach { get; set; } = 72f;
/// <summary>
/// How far above or below the run a point may be and still count as "at" this barricade. 64.
///
/// ⛔ WITHOUT THIS, BARRICADES HAD NO VERTICAL BOUND AT ALL. DistanceToRun flattens Z on
/// purpose and nothing checked it afterwards, so a player a kilometre overhead could rebuild a
/// window and a zombie a kilometre below would stop to tear at one — both measuring the same
/// horizontal distance as somebody standing at the sill.
///
/// ⚠️ 64 COMES FROM THE GEOMETRY, it is not picked. The run sits at CrossOffset (34) on a board
/// Size.z (44) tall, and whoever is interacting stands on the floor beneath it — so the band has
/// to clear roughly a board's height for a player at the sill to work normally. Source maps put
/// storeys 128+ units apart, so 64 rejects the floor above and below without ever interfering
/// with anyone on the same one.
///
/// ⚠️ MEASURED FROM THE RUN, NOT THE ORIGIN, for the same reason DistanceToRun is: the run is
/// where the boards actually are.
/// </summary>
[Property] public float VerticalReach { get; set; } = 64f;
/// <summary>
/// Is <paramref name="point"/> close enough to interact — horizontally AND vertically?
///
/// ⛔ EVERY REACH GATE SHOULD GO THROUGH HERE. There were four (the player's repair, the
/// zombie's cross test, the zombie's tear target, Tortoise's auto-repair) and every one compared
/// DistanceToRun against its own radius with no vertical term at all. One test in one place is
/// what stops the fifth being written the same way.
///
/// ⚠️ DistanceToRun IS STILL THE ORDERING KEY. This answers "may I?", not "which is nearest" —
/// callers picking a best match keep using the flattened distance for that, because adding a
/// vertical term would let a barricade further along the wall beat the one you are standing at.
/// </summary>
public bool InReach( Vector3 point, float horizontal )
{
if ( DistanceToRun( point ) > horizontal ) return false;
// ⚠️ AGAINST THE RUN'S OWN HEIGHT. RunA and RunB share a Z on every barricade the tool
// builds, but averaging costs nothing and stays sane if one is ever sloped.
var runZ = (RunA.z + RunB.z) * 0.5f;
return MathF.Abs( point.z - runZ ) <= VerticalReach;
}
/// <summary>
/// The nearest barricade a point could repair, or null.
///
/// ⚠️ Measured to the RUN, not the origin — a long barricade is repairable
/// anywhere along it, and distance-to-midpoint would make the ends dead.
/// Same reason the zombies' tear check uses DistanceToRun.
///
/// ⚠️ Skips FULL ones, so standing at an intact barricade never swallows the
/// use key from a wallbuy behind it.
/// </summary>
public static Barricade RepairableNear( Vector3 point )
{
Barricade best = null;
float bestDist = float.MaxValue;
foreach ( var b in All )
{
if ( !b.IsValid() || b.IsFull ) continue;
// ⚠️ InReach GATES, DistanceToRun ORDERS. The gate needs the vertical band; choosing
// between several candidates must not, or a barricade further along the wall but level
// with you would beat the one right in front of you.
if ( !b.InReach( point, b.RepairReach ) ) continue;
float d = b.DistanceToRun( point );
if ( d >= bestDist ) continue;
bestDist = d;
best = b;
}
return best;
}
/// <summary>Every live barricade, for the AI and the use trace to scan.</summary>
public static readonly List<Barricade> All = new();
/// <summary>Fully boarded — nothing to repair.</summary>
public bool IsFull => Planks >= MaxPlanks;
/// <summary>Open — a zombie can come through without tearing anything.</summary>
public bool IsOpen => Planks <= 0;
TimeUntil _nextPlank;
GameObject _visual;
readonly List<GameObject> _boards = new();
protected override void OnEnabled()
{
if ( !All.Contains( this ) ) All.Add( this );
BuildCollider();
RebuildVisual();
}
protected override void OnDisabled()
{
All.Remove( this );
ClearNavLink();
}
/// <summary>Next time a failed crossing build is worth retrying.</summary>
TimeUntil _crossRetry;
/// <summary>
/// Keep trying to publish the crossing until it works, then stop.
///
/// ⛔ NEEDED BECAUSE THE NAVMESH IS NOT READY WHEN BARRICADES ARE BUILT. Loading a config calls
/// NavBake.Apply, which marks the mesh dirty and lets it regenerate over the following frames;
/// the barricades are created inside that same rebuild. So the first BuildNavLink almost always
/// fails on a fresh load, and without a retry every window on the map would silently stay absent
/// from the graph — the exact bug this whole change is fixing, reintroduced by timing.
///
/// ⚠️ STOPS DEAD ONCE LINKED, so the cost is a bool test per barricade per frame after the first
/// success. Half-second retries while it is failing, because generation takes a moment and
/// hammering GetClosestPoint every frame on every barricade is not free.
/// </summary>
protected override void OnUpdate()
{
if ( NavLinked ) return;
if ( !_crossRetry ) return;
_crossRetry = 0.5f;
BuildNavLink();
}
/// <summary>
/// Re-read every property and rebuild the wall.
///
/// ⛔ EXISTS BECAUSE OnEnabled RUNS TOO EARLY. `Components.Create<Barricade>()`
/// enables the component the moment it is added, so OnEnabled built the wall
/// from the DEFAULT Size — and the manager assigns the real one on the next
/// line, by which point the geometry already exists. Every barricade came out
/// the default 96u long no matter where the two points were.
///
/// ⚠️ Anything that sets Size/Material/Tint from outside must call this after.
/// </summary>
public void Rebuild()
{
BuildCollider();
RebuildVisual();
RebuildBoards();
// ⚠️ MAY LEGITIMATELY FAIL HERE AND THAT IS NOT AN ERROR. A config load regenerates the
// navmesh (NavBake.Apply sets it dirty) and generation takes several frames, so at the
// moment the barricades are built there is often no mesh to snap to yet. TickCrossing
// retries until it succeeds, which is why this return value is ignored.
BuildNavLink();
}
/// <summary>
/// The volume that stops the player.
///
/// ⚠️ Static, not a rigidbody — it must never be pushed by the player leaning
/// on it, and a window frame has no reason to simulate.
/// </summary>
void BuildCollider()
{
if ( !BlocksPlayer ) return;
// ⛔ UPDATES an existing collider rather than skipping it. This early-
// returned when one was already there, so a Rebuild after the size was
// assigned left the collider at whatever size it was FIRST built with —
// a wall you could see at the right length and walk through at the wrong
// one.
var box = Components.GetOrCreate<BoxCollider>();
box.Scale = Size;
// ⚠️ Tagged so a future collision rule can find it, and so traces that
// want to ignore barricades have a name to filter on. The blocking today
// comes from the collider existing, not from the tag.
if ( !GameObject.Tags.Has( "barricade" ) )
GameObject.Tags.Add( "barricade" );
// ⛔ BULLETS AND THE KNIFE PASS THROUGH. `TagsHelper.PassBullets` is already in
// Weapon.BulletTraceIgnoreTags — SWB's own mechanism for exactly this — so tagging the body
// needs no change to any trace. Using it rather than adding "barricade" to those lists keeps
// the rule where the object is, so the next thing that should be shootable-through says so
// itself instead of being enumerated somewhere else.
//
// ⚠️ WHY IT SHOULD PASS: a barricade is a boarded window you shoot the zombies THROUGH. A
// round stopping on the boards means the horde at the window is unkillable until the boards
// are torn down, which is backwards — the boards are what the horde is removing.
//
// ⚠️ THE COLLIDER STAYS SOLID. Only traces carrying that ignore list pass; the player and
// the zombies are still blocked, which is the whole job of the collider.
if ( !GameObject.Tags.Has( SWB.Shared.TagsHelper.PassBullets ) )
GameObject.Tags.Add( SWB.Shared.TagsHelper.PassBullets );
}
// ── boards ───────────────────────────────────────────────────────────────
/// <summary>
/// Show exactly <see cref="Planks"/> boards.
///
/// ⚠️ Rebuilt wholesale rather than added/removed one at a time. The count is
/// the only state that matters and it changes rarely (a tear, a repair), so a
/// rebuild is cheap and cannot drift out of sync with the number — which an
/// incremental version silently would after the first missed event.
/// </summary>
/// <summary>
/// Build the wall itself — a textured slab from A to B at the set height.
///
/// ⚠️ STEP ONE. There are no boards yet, on purpose: the wall has to be the
/// right size, in the right place, with the right surface before anything is
/// nailed to it. Planks added first would have hidden a wall that was the
/// wrong shape underneath them.
///
/// ⚠️ Scaled from the dev box, which is 50 units a side — so every axis
/// divides by 50 to turn a size in UNITS into a scale.
/// </summary>
void RebuildVisual()
{
_visual?.Destroy();
_visual = null;
var go = Scene.CreateObject();
go.Name = "wall";
go.SetParent( GameObject );
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⚠️ Half the height UP, because the component sits at the BASE of the run
// — a slab centred on the origin would be buried to its waist in the floor.
go.LocalPosition = new Vector3( 0f, 0f, Size.z * 0.5f );
// ⛔ SET EXPLICITLY, AND IT USED NOT TO BE. `SetParent` KEEPS THE CHILD'S
// WORLD TRANSFORM, so an object created at world identity and then
// parented gets a LocalRotation that CANCELS the parent's — leaving this
// slab axis-aligned no matter which way the barricade runs. Measured on a
// barricade built at yaw 39.3: parent 39.3, wall 0.
//
// The boards never had this bug purely because they set LocalRotation for
// their alternating roll, which happens to overwrite the cancellation. So
// the symptom was "the planks follow the two points and the wall under
// them does not" — one missing line, not two different systems.
//
// ⚠️ Position is assigned above and scale below, which is why only
// rotation was ever visibly wrong.
go.LocalRotation = Rotation.Identity;
var r = go.Components.Create<ModelRenderer>();
r.Model = Model.Load( "models/dev/box.vmdl" );
// ⛔ SCALED FROM THE MODEL'S REAL BOUNDS, NOT AN ASSUMED 50 UNITS. The
// dev box was taken to be 50 a side and it is not — which is why the wall
// came out the wrong length no matter what the two points were. Reading
// Bounds makes the maths independent of whichever box model is used.
var box = r.Model?.Bounds.Size ?? new Vector3( 50f, 50f, 50f );
go.LocalScale = new Vector3(
box.x > 0.01f ? Size.x / box.x : 1f,
box.y > 0.01f ? Size.y / box.y : 1f,
box.z > 0.01f ? Size.z / box.z : 1f );
r.Tint = Tint;
// ⚠️ Missing material LEAVES IT UNTEXTURED rather than failing — the same
// rule DebrisManager.ApplyMaterial follows. A bad path in a config must
// not delete the wall.
var mat = Material.Load( MaterialPath );
if ( mat is not null ) r.MaterialOverride = mat;
else Log.Warning( $"[nz] barricade material not found: {MaterialPath} — left untextured" );
_visual = go;
}
/// <summary>
/// The boards, nailed ABOVE the wall across the same height span again.
///
/// So a barricade is a low solid sill (Size.z) with <see cref="MaxPlanks"/>
/// boards stacked over it, covering another Size.z of opening. Tear them all
/// off and what is left is a wall low enough to vault — which is exactly the
/// shape the mechanic needs: the boards are the obstacle, the sill is not.
///
/// ⛔ BOARD i ALWAYS SITS IN SLOT i. It would be simpler to pack the remaining
/// boards down from the bottom, and it would be wrong: a half-torn barricade
/// would then look like a short intact one, and the gap a zombie is climbing
/// through would not be where the missing board was.
/// </summary>
void RebuildBoards()
{
foreach ( var b in _boards )
b?.Destroy();
_boards.Clear();
if ( Planks <= 0 ) return;
// The boarded region is the same height as the wall, divided into slots.
float slice = Size.z / MaxPlanks;
for ( int i = 0; i < Planks; i++ )
{
var go = Scene.CreateObject();
go.Name = $"board {i}";
go.SetParent( GameObject );
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⚠️ Size.z + … — the boards START at the top of the wall, not at its
// base. Sharing the wall's span would hide them inside it.
go.LocalPosition = new Vector3( 0f, 0f, Size.z + slice * (i + 0.5f) );
// A little roll, alternating, so it reads as nailed-on scrap rather
// than a stack of shelves.
go.LocalRotation = Rotation.From( 0f, 0f, i % 2 == 0 ? 2.5f : -2.5f );
var r = go.Components.Create<ModelRenderer>();
r.Model = Model.Load( "models/dev/box.vmdl" );
r.Tint = Tint;
var mat = Material.Load( MaterialPath );
if ( mat is not null ) r.MaterialOverride = mat;
// ⚠️ Scaled off the model's REAL bounds, the same as the wall — an
// assumed 50 is what made the wall the wrong length earlier.
var box = r.Model?.Bounds.Size ?? new Vector3( 50f, 50f, 50f );
// Longer than the run so the ends bite into the frame, and THINNER
// than the wall.
//
// ⛔ IT USED TO BE `Size.y + 2f` — THICKER than the wall it is nailed
// to. With the defaults that made each board 8 deep but only ~4.4
// tall, i.e. deeper than it was tall: a stack of square beams rather
// than boards. The stated intent was for them to "sit ON" the
// barricade, but they are built ABOVE the wall, not against its face,
// so nothing was ever hidden by being thinner — the extra depth
// bought nothing and cost the silhouette.
var want = new Vector3( Size.x + 6f, Size.y * BoardThickness, slice * 0.6f );
go.LocalScale = new Vector3(
box.x > 0.01f ? want.x / box.x : 1f,
box.y > 0.01f ? want.y / box.y : 1f,
box.z > 0.01f ? want.z / box.z : 1f );
_boards.Add( go );
}
}
/// <summary>Set how many boards this starts with. Clamped to [0, MaxPlanks].</summary>
public void SetBoards( int count )
{
Planks = Math.Clamp( count, 0, MaxPlanks );
RebuildBoards();
}
// ── tearing (zombies) ────────────────────────────────────────────────────
/// <summary>
/// A zombie rips one board off. Returns false when there was nothing left.
///
/// The original's `DoPlankPullSequence` — one board per pull, never the lot.
/// </summary>
public bool TearPlank()
{
if ( Planks <= 0 ) return false;
Planks--;
RebuildBoards();
Announce();
// ⚠️ Played at the BOARD that just came off, not at the barricade's origin
// — the origin is the midpoint of the run, so on a long barricade every
// snap would come from the middle wherever it actually happened.
float slice = Size.z / MaxPlanks;
var at = WorldPosition + Vector3.Up * (Size.z + slice * (Planks + 0.5f));
NZSound.Play( NZSound.BarricadeBreak, at );
return true;
}
// ── repairing (players) ──────────────────────────────────────────────────
/// <summary>
/// Nail one board back on, and pay for it.
///
/// ⚠️ ONE BOARD PER CALL, rate-limited. The original repairs a single random
/// torn plank per Use and sets `self.NextPlank = CurTime() + time`, so holding
/// the key rebuilds the window one board at a time rather than all six at
/// once — and the points come a board at a time with it.
/// </summary>
public string Repair( NZPlayer player )
{
if ( !player.IsValid() ) return "no player";
if ( IsFull ) return "already boarded up";
if ( !_nextPlank ) return ""; // still on the per-board cooldown
// ⚠ TORTOISE m4 QUICK HANDS SCALES THE COOLDOWN AT THE ONE PLACE IT IS SET. `RepairInterval`
// is an authored `[Property]` shared by every player who touches this barricade, so an
// augment that ASSIGNED it would hand the faster boards to everyone and leave them there
// after the perk was lost — the trap `NZInventory.HolsterTime` documents.
_nextPlank = RepairInterval * TortoiseAugments.RepairIntervalScale( player );
// ⛔ THE HOST OWNS THE COUNT. A client that boarded the window locally would be corrected
// back by the host's next broadcast a moment later — a board that appears and then pops off
// again. So it ASKS, and its own board arrives through the same path everybody else's does.
//
// ⛔ AND A CLIENT IS PAID WHEN THE HOST SAYS THE BOARD WENT UP, NOT WHEN IT ASKED (2026-09-27).
// The pay below used to run here for a client too, and the host refuses a full window without
// a word — so a teammate's board landing first left the client with the points and Handyman's
// kill wave for nothing, and Fortifier's auto-repair made that routine on a shared window.
// `NZNet.BarricadeRepaired` brings the answer back and runs `Paid` on this machine. The
// cooldown above stays local and immediate.
if ( NZGame.IsClient )
{
var i = Index;
if ( i >= 0 ) NZNet.BarricadeRepairAsk( i );
return $"asked the host for a board ({Planks}/{MaxPlanks})";
}
Planks++;
RebuildBoards();
Announce();
return Paid( player );
}
/// <summary>
/// What one board earns the player who put it up: Handyman, the points, the voice line and the
/// sound.
///
/// ⚠️ ALWAYS ON THE REPAIRER'S OWN MACHINE — the host's for its own boards, straight from
/// `Repair`; a client's once the host has confirmed the board, through `NZNet.BarricadeRepaired`.
/// </summary>
public string Paid( NZPlayer player )
{
if ( !player.IsValid() ) return "";
// ⚠ m3 HANDYMAN FIRES ON THE BOARD, not on the keypress, so it cannot pay out while the
// repair is on cooldown. It is handed THIS barricade, not a point (2026-10-03): it kills
// only the zombies tearing at this window, no longer everything within 200u of it.
TortoiseAugments.OnBarricadeRepaired( player, this );
player.AddPoints( RepairPoints );
// ⚠️ PER BOARD, WHICH IS WHY IT IS RATIONED. Rebuilding a full barricade is six of these in
// a row; at 15% with a 10s cooldown that is a remark, not a monologue.
CharacterVoice.Say( "doground", player );
NZSound.Play( NZSound.BarricadeRepair, WorldPosition );
return $"repaired a board ({Planks}/{MaxPlanks}) +{RepairPoints}";
}
/// <summary>
/// Put every board back, without paying. For round resets.
///
/// ⚠️ NOT called Reset — Component.Reset already exists and a same-named
/// method HIDES it, so an engine call to Reset would silently get this
/// instead. The compiler warned; renaming is cheaper than reasoning about
/// which one any given caller reaches.
/// </summary>
public void Reboard()
{
Planks = MaxPlanks;
RebuildBoards();
Announce();
}
/// <summary>
/// Where this barricade sits in the config, which is its identity across machines.
///
/// ⛔ GUIDS ARE USELESS HERE. Barricades are BUILT at runtime by `BarricadeManager.Rebuild`
/// from `ActiveConfig.Current.Barricades`, on every machine separately — so each machine's
/// barricade has a different guid for the same window. What they do share is the list, in
/// order, because the client took the host's config before the map loaded.
/// </summary>
public int Index => All.IndexOf( this );
/// <summary>
/// Tell every client how many boards this window has. Host only, no-op in single player.
///
/// ⚠️ AT THE ONE PLACE THE COUNT MOVES, so tear, repair and reboard are all covered and a
/// future fourth way of changing it cannot forget to announce.
/// </summary>
void Announce()
{
if ( !Networking.IsActive || !NZGame.IsHost ) return;
var i = Index;
if ( i >= 0 ) NZNet.BarricadePlanks( i, Planks );
}
/// <summary>
/// The host says this is how many boards there are. Clients only.
///
/// ⚠️ IT REBUILDS RATHER THAN ANIMATING. The boards are rebuilt from the count every time it
/// changes on the host too — `RebuildBoards` is what `TearPlank` and `Repair` both call — so a
/// client applying the same count reaches the same visual state by the same path.
/// </summary>
public void SetPlanksFromHost( int planks )
{
planks = Math.Clamp( planks, 0, MaxPlanks );
if ( planks == Planks ) return;
Planks = planks;
RebuildBoards();
}
/// <summary>
/// A client asked for a board back and the host is granting it — or refusing, when the window is
/// already full. True when a board went up.
///
/// ⚠️ NO POINTS, NO COOLDOWN, NO AUGMENTS HERE — those belong to the player who pressed the key,
/// on their own machine: the cooldown already ran there, and the pay runs there once this says
/// yes (`NZNet.BarricadeRepaired`). This is only the half the host is authoritative over: the
/// count, and telling everybody.
/// </summary>
public bool AddPlankFromClient()
{
if ( IsFull ) return false;
Planks++;
RebuildBoards();
Announce();
return true;
}
/// <summary>Strip it open, for testing.</summary>
public void Clear()
{
Planks = 0;
RebuildBoards();
Announce();
}
}