Component that represents a player-placed object (placeable) with four kinds: SlickBar, Wall, Stand, Springboard. It stores durability, lifetime, size, owner and net id, spawns mirrored copies across clients, updates per-kind behavior each tick (slip zombies, block walls, launch players), draws floor outlines with line renderers and a point light, and handles use, eviction, expiry and refund logic.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Which placeable this is. One per Banana Colada major.
/// </summary>
public enum PlaceKind
{
/// <summary>M1 — a bar on the floor; zombies crossing it slip.</summary>
SlickBar,
/// <summary>M2 — a wall you can walk through and they have to break.</summary>
Wall,
/// <summary>M3 — the horde targets this instead of you.</summary>
Stand,
/// <summary>M4 — a pad that flings you.</summary>
Springboard,
}
/// <summary>
/// A thing Banana Colada put on the floor.
///
/// ⛔ THE FIRST PLAYER-PLACED WORLD OBJECT IN THE PROJECT, and that is the point of the perk. Nothing
/// else lets a player put something into the world that zombies interact with — barricades are map
/// furniture the mapper placed, and the fire/slow/fallout pits are timed auras with no object to
/// break. So this file owns a shape nothing else here has: an object with DURABILITY, a LIFETIME, a
/// SIZE, and a CHARGE cost, that dies from either end.
///
/// ⛔ FOUR KINDS, ONE COMPONENT, DELIBERATELY — the same call `Pickup` made against `Powerup`, and
/// for the same reason its notes give. All four share the whole spine: charge to place, a use budget,
/// a clock, a footprint, a cap on how many exist, and the m5 charge refund. Four bespoke components
/// would be four places to fix the day the refund maths changes.
///
/// ⚠️ WHAT DIFFERS IS ONE METHOD EACH. `Spec` holds the numbers and `Kind` picks the behaviour; the
/// per-kind work is small enough that a subclass per kind would be more ceremony than code.
///
/// ⛔ THE LOOK IS `LineRenderer` AND `PointLight`, NOT A TRANSLUCENT MODEL, AND THAT IS A FORCED
/// CHOICE. "Semi-transparent light yellow" was asked for, and this project has NO translucent
/// material — `WallBuyManager` and `TortoiseAugments` both tint `models/dev/box.vmdl` with an alpha
/// below 1 and neither has ever been checked in game, so whether that renders translucent or opaque
/// is unknown. Additive `LineRenderer`s and a light are the one translucent look this project has
/// actually been seen to produce (`PitVisual`, screenshotted today), so the footprints are drawn
/// with them rather than with a box nobody has verified.
/// </summary>
public sealed class Placeable : Component
{
// ══ the shared colour ════════════════════════════════════════════════════
static Color? _colour;
/// <summary>
/// The light yellow every placeable shares.
///
/// ⚠️ ONE COLOUR FOR ALL FOUR, BY REQUEST, which means the kinds have to be told apart by SHAPE
/// rather than by hue — a long bar, a tall panel, a ring, a small square. That is a constraint on
/// the footprints below, not a detail of this field.
/// </summary>
public static Color Colour
{
get => _colour ?? new Color( 1f, 0.93f, 0.45f );
set => _colour = value;
}
static float? _alpha;
/// <summary>How solid the footprint reads, 0-1. 0.55 — "semi-transparent".</summary>
public static float Alpha { get => _alpha ?? 0.55f; set => _alpha = value; }
// ══ per-kind numbers ═════════════════════════════════════════════════════
/// <summary>What one kind of placeable is made of.</summary>
public sealed class Spec
{
/// <summary>How many uses it has before it dies. Zombies crossed, hits taken, launches.</summary>
public int Durability = 6;
/// <summary>How long it lives at most, in seconds.</summary>
public float Seconds = 20f;
/// <summary>Its footprint, in world units. Read per kind — see `SizeNote`.</summary>
public float Size = 120f;
/// <summary>What `Size` actually measures for this kind, for the report to print.</summary>
public string SizeNote = "radius";
/// <summary>How many of this kind may exist at once.</summary>
public int Cap = 2;
}
/// <summary>
/// The numbers for one kind.
///
/// ⛔ BUILT FRESH FROM LITERALS ON EVERY READ, NOT A CACHED STATIC TABLE. A static's VALUE
/// survives a hotload but its initialiser does not re-run (INSTRUCTIONS.md §1), so a cached
/// dictionary would keep serving pre-edit numbers and every retune would appear to do nothing.
/// `PitVisual.LookFor` is the same shape for the same reason.
/// </summary>
public static Spec SpecFor( PlaceKind kind ) => kind switch
{
// ⚠️ DURABILITY IS "ZOMBIES THAT CROSSED IT", AND IT IS 60 — TEN TIMES THE ORIGINAL SIX, by
// request after play-testing, the same call that took the Banana Stand from 8 to 80.
//
// ⛔ THE OLD NOTE SAID "SIX IS A CLUMP, NOT A HORDE" and that was exactly the problem: a bar
// laid in a doorway meets a horde, not a clump, and six zombies is a second of it. The
// reasoning was sound and the number was set for a fight that does not happen.
PlaceKind.SlickBar => new Spec
{
Durability = 60,
Seconds = 20f,
Size = 160f,
SizeNote = "bar length",
Cap = 2,
},
// ⚠️ DURABILITY IS HITS TAKEN, AND IT IS 80 — set by hand rather than by the tenfold pass the
// Stand (8 -> 80) and the Slick Bar (6 -> 60) got. 120 was tried first and came down: the
// wall now BLOCKS as well as absorbs, so it holds a horde for its whole life instead of
// leaking zombies through, and the same number of swings buys considerably more time.
//
// ⛔ `Barricade.MaxPlanks` IS NO LONGER THE COMPARISON TO KEEP IT NEAR, and that was the
// flaw in the old number. A barricade is one window that a handful of zombies reach at a
// time; this is placed in front of a horde deliberately, and twelve swings is a couple of
// seconds of one.
PlaceKind.Wall => new Spec
{
Durability = 80,
Seconds = 30f,
Size = 140f,
SizeNote = "wall width",
Cap = 1,
},
// ⚠️ DURABILITY IS HITS TAKEN, AND IT IS 80 — TEN TIMES WHAT IT WAS, by request after
// play-testing. It read as bait that died before it had drawn anything: the stand pulls
// every zombie within 900u, so the horde it attracts is exactly what chews through eight
// swings in a second or two.
//
// ⛔ THE OLD NOTE SAID THE OPPOSITE — "lower than the wall on purpose ... bait that outlasts
// the emergency stops being a decision" — and it is kept here because the reasoning was
// sound and the NUMBER was still wrong. The decision it protects is real, but 8 was not the
// price of that decision, it was the stand not functioning.
PlaceKind.Stand => new Spec
{
Durability = 80,
Seconds = 25f,
Size = 900f,
SizeNote = "attraction radius",
Cap = 1,
},
// ⚠️ DURABILITY IS LAUNCHES. Small footprint because you have to choose to step on it —
// a wide pad would fling you every time you walked past.
// ⚠️ THE SPRINGBOARD IS THE `_` ARM RATHER THAN A NAMED ONE, and that is to satisfy CS8524
// rather than laziness. A switch over an enum is not exhaustive to the compiler — a cast
// integer is a legal `PlaceKind` — so one arm has to be the default. Making the LAST kind
// the fallback means a fifth kind added without a spec silently gets springboard numbers,
// which is why `SizeNote` is printed in every report: a wall claiming "pad radius" is the
// tell that its arm is missing.
_ => new Spec
{
Durability = 5,
Seconds = 30f,
Size = 48f,
SizeNote = "pad radius",
Cap = 2,
},
};
// ══ live state ═══════════════════════════════════════════════════════════
/// <summary>Which kind this is.</summary>
public PlaceKind Kind { get; set; } = PlaceKind.Stand;
/// <summary>Who put it there. Needed for kill credit and for the m5 refund.</summary>
public NZPlayer Owner { get; set; }
/// <summary>Uses left. At 0 it dies by durability, which is what m5 pays out on.</summary>
public int Left { get; set; }
/// <summary>Uses it started with, for the report and the HUD.</summary>
public int Total { get; set; }
/// <summary>Its resolved footprint, after m2.</summary>
/// <summary>
/// The same id for this placeable on every machine.
///
/// ⛔ A PLACEABLE IS BUILT LOCALLY ON EACH MACHINE, NOT NETWORK-SPAWNED, so `GameObject.Id`
/// names a different object on each of them. The placer mints this and sends it, which is the
/// only handle "destroy that one" can travel by. Copied from `Powerup.NetId`, which exists for
/// exactly the same reason.
/// </summary>
public Guid NetId { get; set; }
/// <summary>Find a placeable by the id every machine shares. Null if it is already gone.</summary>
public static Placeable ById( Guid id )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() || id == default ) return null;
foreach ( var p in scene.GetAllComponents<Placeable>() )
if ( p.IsValid() && p.NetId == id ) return p;
return null;
}
public float Size { get; set; }
/// <summary>Its resolved lifetime, after m4.</summary>
public float Life { get; set; }
/// <summary>When it expires.</summary>
public TimeUntil Dies { get; set; }
PointLight _light;
readonly List<LineRenderer> _lines = new();
bool _died;
/// <summary>
/// Who is standing on this pad right now.
///
/// ⛔ THE LAUNCH FIRES ON ENTERING, NOT WHILE INSIDE, and this set is what makes that
/// difference. A radius test alone runs every frame, so standing on the pad would spend every
/// use in a fraction of a second and the pad would vanish under you. Tracking who is already on
/// it means one step in is one launch, and you have to leave and come back for another.
///
/// ⚠️ AND IT IS PER-PAD, not per-player: two pads should each be able to fling the same player.
/// </summary>
readonly HashSet<NZPlayer> _onPad = new();
static float? _launchSpeed;
/// <summary>
/// Upward speed a springboard gives, in units per second. 700.
///
/// ⚠️ NULLABLE-BACKED — a static's VALUE survives a hotload but its initialiser does not re-run
/// (INSTRUCTIONS.md §1), so a plain `= 700f` would keep serving the pre-edit number and every
/// retune would look like it did nothing.
///
/// ⚠️ 700 IS ROUGHLY TWICE A JUMP. High enough to clear a barricade or reach a ledge, low enough
/// that the landing is survivable — fall damage IS in this game (corrected 2026-09-14: a spawn
/// that kept its falling velocity charged 736 points for the descent), and a launch that leaves you
/// helpless in the air for four seconds during a horde is a punishment, not a tool.
/// </summary>
public static float LaunchSpeed { get => _launchSpeed ?? 700f; set => _launchSpeed = value; }
/// <summary>Everything alive right now, oldest first.</summary>
public static IEnumerable<Placeable> All
=> Game.ActiveScene?.GetAllComponents<Placeable>().Where( p => p.IsValid() )
?? Enumerable.Empty<Placeable>();
/// <summary>Everything of one kind belonging to one player.</summary>
public static List<Placeable> OwnedBy( NZPlayer player, PlaceKind kind )
=> All.Where( p => p.Kind == kind && p.Owner == player ).ToList();
/// <summary>
/// Put one in the world.
///
/// ⛔ IT SNAPS TO THE FLOOR THROUGH `PitVisual.GroundAt`, reusing the trace that file exists for
/// rather than writing a second one. A placeable that hovers is the same failure a hovering pit
/// was, and that method already ignores the player and zombies, traces from above so it cannot
/// start solid, and falls back to the given point rather than the origin.
///
/// ⚠️ THE CAP EVICTS THE OLDEST RATHER THAN REFUSING. A refusal at the cap means a key that
/// sometimes does nothing, and the player cannot see why; replacing the oldest is always
/// legible — the thing you placed first vanishes as the new one appears.
///
/// ⛔ AN EVICTED PLACEABLE MUST NOT PAY THE m5 REFUND. It did not run out of durability, it was
/// pushed out — refunding it would let a player farm charge by spamming placements at the cap.
/// `_died` is set before the destroy for exactly that reason.
/// </summary>
/// <param name="netId">Shared identity. Minted here when placing; passed in when mirroring.</param>
/// <param name="yaw">NaN = face the owner. Passed in when mirroring, because a proxy's
/// `EyeAngles` on another machine is not the angle the placer was facing.</param>
/// <param name="total">-1 = resolve from the owner's augments. Passed in when mirroring.</param>
/// <param name="announce">False when this IS the mirror, so it cannot echo.</param>
public static Placeable Spawn( NZPlayer owner, PlaceKind kind, Vector3 at,
Guid netId = default, float yaw = float.NaN,
int total = -1, float size = -1f, float life = -1f, bool announce = true )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() || !owner.IsValid() ) return null;
var spec = SpecFor( kind );
var mine = OwnedBy( owner, kind );
var cap = Math.Max( 1, spec.Cap );
while ( mine.Count >= cap )
{
var oldest = mine[0];
mine.RemoveAt( 0 );
if ( oldest.IsValid() )
{
oldest._died = true; // evicted, not spent — no refund
oldest.GameObject?.Destroy();
}
}
var go = scene.CreateObject();
go.Name = $"nz_place_{kind}".ToLowerInvariant();
go.WorldPosition = PitVisual.GroundAt( at );
// ⛔ FACED TO THE PLAYER, NOT LEFT AT IDENTITY. Nothing set this, so every placeable spawned
// world-aligned — and the bar and the wall are both drawn ACROSS `WorldRotation.Right`, so
// they always lay along world +Y no matter which way you were looking. Placing a bar in a
// doorway worked or did not depending on which way the doorway happened to face.
//
// ⚠️ YAW ONLY. `EyeAngles` carries pitch, and inheriting it would tip a floor decal up into
// the air when placed while looking down — which is the normal way to place one.
//
// ⚠️ THIS ALSO FIXES THE CROSSING TEST FOR FREE. `TickSlickBar` projects onto the same
// `WorldRotation.Right`/`Up` axes the strip is drawn from, so the box it tests and the bar
// you see are the same rectangle by construction rather than by two matching literals.
go.WorldRotation = Rotation.FromYaw( float.IsNaN( yaw )
? (owner.IsValid() ? owner.EyeAngles.yaw : 0f)
: yaw );
go.NetworkMode = NetworkMode.Never;
var p = go.Components.Create<Placeable>();
p.Kind = kind;
p.Owner = owner;
// ⚠️ THE AUGMENT SCALES ARE READ HERE AND STORED, not re-read per tick. Equipping m2 or m4
// should not resize something already on the floor — a placeable is a commitment made at the
// moment it was placed, and a live-resizing one would make the durability maths unreadable.
// ⛔ THE RESOLVED SHAPE TRAVELS; IT IS NOT RECOMPUTED ON THE FAR SIDE. Every one of these
// three reads the OWNER'S augments (m2 size, m4 durability, life), and on any machine but
// the placer's that owner is a proxy with none of them — so a mirrored copy would come out
// the base size while the real one is bigger. A wall you can see and a wall that blocks
// would be different rectangles.
p.Total = total >= 0
? Math.Max( 1, total )
: Math.Max( 1, (int)MathF.Round( spec.Durability * BananaAugments.DurabilityScale( owner ) ) );
p.Left = p.Total;
p.Size = size >= 0f
? size
: MathF.Max( 1f, spec.Size * BananaAugments.SizeScale( owner ) );
p.Life = life >= 0f
? life
: MathF.Max( 0.5f, spec.Seconds * BananaAugments.LifeScale( owner ) );
p.Dies = p.Life;
p.NetId = netId == default ? Guid.NewGuid() : netId;
// ⛔ EVERY MACHINE GETS ONE, BECAUSE EVERY MACHINE NEEDS IT FOR A DIFFERENT REASON. The
// placer sees what they built; other players must see an obstacle they can walk through and
// zombies cannot; and the HOST must have it at all, because every question a placeable
// answers — `WallBlocking`, `AbsorbWallSwing`, the slick bar's crossing test, the
// springboard's pad — is asked by zombie code, which only the host runs.
//
// ⚠️ SO A CLIENT'S WALL DID NOT BLOCK ANYTHING AND A CLIENT'S BAR TRIPPED NOBODY. Not a
// visual bug with a gameplay footnote — the whole augment.
if ( announce && Networking.IsActive && Connection.Local is not null )
NZNet.PlaceableSpawned( Connection.Local.Id.ToString(), p.NetId,
NZPlayers.OwnerOf( owner.GameObject ), (int)kind,
go.WorldPosition, go.WorldRotation.Yaw(),
p.Total, p.Size, p.Life );
return p;
}
protected override void OnStart() => BuildLook();
protected override void OnUpdate()
{
// ⛔ TIME-OUT AND DURABILITY-DEATH ARE DIFFERENT EVENTS AND ONLY ONE PAYS. m5 refunds unspent
// TIME, so a placeable that ran out of time has nothing to refund by definition — setting
// `_died` here is what stops `OnDestroy` treating an expiry as a spend.
if ( Dies <= 0f )
{
_died = true;
Log.Info( $"[nz-place] {Kind} expired — {Left}/{Total} use(s) unspent, no refund" );
GameObject?.Destroy();
return;
}
// ⚠️ BEFORE `Fade`, so a pad that is used up on this frame destroys itself without first
// being faded for a frame it no longer has.
if ( Kind == PlaceKind.Springboard ) TickSpringboard();
if ( Kind == PlaceKind.SlickBar ) TickSlickBar();
if ( Kind == PlaceKind.Wall ) TickWall();
Fade();
}
/// <summary>
/// M4 Springboard — fling anyone who steps on the pad straight up.
///
/// ⛔ PLAYERS ONLY, DELIBERATELY. "Any player that goes in is launched" is the ask, and a pad
/// that also threw zombies would be a crowd-control tool rather than a mobility one — it would
/// scatter the horde the Banana Stand exists to gather.
///
/// ⚠️ FLAT XY, THE WAY `TortoiseRing.Contains` TESTS. The pad is painted on the floor, so a
/// player on a walkway directly above it should not be flung; the Z gate below is what stops
/// that, and it is generous enough to cover standing on a slope.
/// </summary>
void TickSpringboard()
{
var scene = Scene;
if ( !scene.IsValid() ) return;
foreach ( var player in scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !player.IsValid() ) continue;
var d = player.WorldPosition - WorldPosition;
var inside = d.WithZ( 0f ).Length <= Size && MathF.Abs( d.z ) <= PadHeight;
if ( !inside )
{
// ⚠️ REMOVED ON LEAVING so stepping off and back on launches again.
_onPad.Remove( player );
continue;
}
// Already stood here — do not fling them a second time for not moving.
if ( !_onPad.Add( player ) ) continue;
Launch( player );
// ⚠️ ONE USE PER LAUNCH, and `Use` may destroy this object — so nothing may touch
// `_onPad` or any field after it returns true.
if ( Use( 1 ) ) return;
}
}
/// <summary>
/// M1 Slick Bar — a zombie crossing the bar goes over.
///
/// ⛔ ZOMBIES ONLY, WHICH IS THE MIRROR OF THE SPRINGBOARD. The bar is placed in a doorway to
/// stop the horde; a player who slipped on their own bar would make it a trap rather than a
/// tool. The original agrees — `perk_powerup_banana_slide/init.lua` gates on
/// `ent:IsValidZombie()` before playing anything.
///
/// ⚠️ A BOX, NOT A RADIUS. The bar is drawn as a strip across the player's facing (see
/// `Strip`), so the test has to match that shape or zombies would slip walking past the ends of
/// a bar they never crossed. `Size` is its LENGTH across; `BarDepth` is how thick it is.
///
/// ⚠️ ONE SLIP PER ZOMBIE PER BAR, tracked the same way the springboard tracks players — the
/// clip roots them in place for its duration, so without this the same zombie would re-trigger
/// every frame it lay there and eat the whole bar.
/// </summary>
void TickSlickBar()
{
var scene = Scene;
if ( !scene.IsValid() ) return;
var across = WorldRotation.Right.WithZ( 0f ).Normal;
var along = Vector3.Cross( across, Vector3.Up ).Normal;
foreach ( var z in scene.GetAllComponents<ZombieAI>().ToList() )
{
if ( !z.IsValid() ) continue;
var d = z.WorldPosition - WorldPosition;
// ⚠️ PROJECTED ONTO THE BAR'S OWN AXES rather than compared as a distance, so the
// footprint is the rectangle that is drawn and not a circle around its middle.
var onto = MathF.Abs( d.Dot( across ) );
var thru = MathF.Abs( d.Dot( along ) );
var inside = onto <= Size * 0.5f && thru <= BarDepth * 0.5f
&& MathF.Abs( d.z ) <= PadHeight;
if ( !inside ) { _slipped.Remove( z ); continue; }
if ( !_slipped.Add( z ) ) continue;
// ⛔ A PRATFALL, NOT ONE OF THE AUTHORED CLIPS. The original picks at random from
// `SlipGunSequences`, and all three of those are staged but unusable — see
// `ZombieAI.PlayPratfall` for what each one does. Two of them render the zombie
// INVISIBLE, which is worse than not animating at all.
//
// ⚠️ THE RANDOM PICK IS THEREFORE GONE FOR NOW, and `WalkerAnimations.Slip` is left in
// place holding the two that compile. Restoring the original behaviour is one line here
// the day the DMX converts cleanly.
if ( !z.PlayPratfall( SlipSeconds ) ) continue;
// ⚠️ POSITIONED, AND AT THE ZOMBIE not the bar — with several going over at once the
// sound should come from each of them.
NZSound.Play( NZSound.BananaSlip, z.WorldPosition );
Log.Info( $"[nz-place] slick bar — zombie went over"
+ $" — {Left - 1}/{Total} use(s) left" );
if ( Use( 1 ) ) return;
}
}
/// <summary>
/// Hold zombies on their own side of the wall.
///
/// ⛔ WITHOUT THIS THEY WALK STRAIGHT THROUGH IT. Stopping to attack is an AI decision made
/// when one is in reach AND `CanAttack()` is true — so a zombie on attack cooldown, or one
/// moving fast enough to clear the 32u face in a frame or two, simply carries on. "Must stop
/// and hit it" needs something that is true every frame, not only when the AI happens to look.
///
/// ⛔ AND IT IS DONE BY POSITION, NOT BY A COLLIDER, FOR TWO REASONS. A collider would block the
/// PLAYER — and M2 is the ONE-WAY wall, the whole promise is that you pass and they do not.
/// Zombies also move by `NavMeshAgent`, which drives the transform against a mesh baked long
/// before this wall existed, so a physics body is not what it obeys.
///
/// ⚠️ PUSHED TO THE NEAREST FACE, not to a remembered side. A zombie already past the wall when
/// it is placed is let go forward rather than dragged back through it, which is both kinder and
/// avoids a body being yanked across the very line it is meant to respect.
/// </summary>
void TickWall()
{
var scene = Scene;
if ( !scene.IsValid() ) return;
var across = WorldRotation.Right.WithZ( 0f ).Normal;
var through = Vector3.Cross( across, Vector3.Up ).Normal;
var half = WallDepth * 0.5f;
foreach ( var z in scene.GetAllComponents<ZombieAI>().ToList() )
{
if ( !z.IsValid() ) continue;
// ⚠️ THE SAME EXEMPTION THE AI USES. A hound ignores barricades and must ignore this.
if ( z.Variant?.IgnoresBarricades == true ) continue;
var d = z.WorldPosition - WorldPosition;
if ( MathF.Abs( d.Dot( across ) ) > Size * 0.5f ) continue;
if ( d.z < -WallHeight || d.z > WallHeight ) continue;
var depth = d.Dot( through );
if ( MathF.Abs( depth ) >= half ) continue;
// ⚠️ A ZOMBIE EXACTLY ON THE LINE IS SENT BACK THE WAY IT IS FACING, because `depth` of
// zero gives no side to prefer and leaving it there means it stands inside the wall.
var side = depth > 0.0001f ? 1f
: depth < -0.0001f ? -1f
: (z.WorldRotation.Forward.Dot( through ) >= 0f ? -1f : 1f);
// ⚠️ THE MARGIN GOES ALONG `side`, NOT ADDED RAW. Written as `side * (...) + 1f` this
// worked from the positive side and FAILED from the negative one: at depth -5 with a
// 16u half-depth it pushed -10 and landed at -15, still inside the wall, so zombies
// approaching from one side stuck in it and jittered.
z.WorldPosition += through * (side * (half - MathF.Abs( depth ) + 1f));
}
}
/// <summary>
/// The M2 wall standing between something at <paramref name="at"/> and whatever is past it,
/// or null.
///
/// ⛔ THE SAME SHAPE THE WALL IS DRAWN AS. `Strip( WorldRotation.Right, Size, 10f )` plus 84u
/// uprights — so the test is a box `Size` long across Right, `WallDepth` thick through it, and
/// `WallHeight` tall. Testing a radius instead would stop zombies walking PAST the ends of a
/// wall they never touched, which is how a doorway blocker becomes an area denial field.
///
/// ⚠️ REACH IS ADDED ON THE THROUGH AXIS ONLY. A zombie stops when it is close enough to swing
/// at the face of the wall; being near its far END is not being blocked by it.
///
/// ⚠️ IT LIVES HERE RATHER THAN IN `ZombieAI` so the rectangle that blocks and the rectangle
/// that is drawn cannot drift apart — the slick bar's crossing test is placed the same way for
/// the same reason.
/// </summary>
public static Placeable WallBlocking( Vector3 at, float reach )
{
foreach ( var p in All )
{
if ( p.Kind != PlaceKind.Wall ) continue;
var across = p.WorldRotation.Right.WithZ( 0f ).Normal;
var through = Vector3.Cross( across, Vector3.Up ).Normal;
var d = at - p.WorldPosition;
if ( MathF.Abs( d.Dot( across ) ) > p.Size * 0.5f ) continue;
if ( MathF.Abs( d.Dot( through ) ) > WallDepth * 0.5f + reach ) continue;
if ( d.z < -WallHeight || d.z > WallHeight ) continue;
return p;
}
return null;
}
static float? _wallDepth;
/// <summary>How thick the wall is through its face. 32.</summary>
public static float WallDepth { get => _wallDepth ?? 32f; set => _wallDepth = value; }
static float? _wallHeight;
/// <summary>
/// How tall the wall blocks, in units. 84.
///
/// ⚠️ MATCHES THE UPRIGHTS IT IS DRAWN WITH, which are 84u — a wall that stopped zombies higher
/// than it appears would block things walking over a ledge above it.
/// </summary>
public static float WallHeight { get => _wallHeight ?? 84f; set => _wallHeight = value; }
/// <summary>
/// A zombie swings at the wall: spend a use, and say whether the swing was absorbed.
///
/// ⚠️ THE SAME CONTRACT AS `BananaStand.AbsorbSwing` — true means the player takes nothing this
/// hit, and for the same reason: the zombie is hitting the object, not the person behind it.
/// </summary>
public static bool AbsorbWallSwing( Vector3 at, float reach )
{
var wall = WallBlocking( at, reach );
if ( wall is null ) return false;
var dead = wall.Use();
Log.Info( $"[nz-place] wall hit — {wall.Left}/{wall.Total} left"
+ (dead ? " · BROKEN" : "") );
return true;
}
/// <summary>Zombies that have already gone over on this bar.</summary>
readonly HashSet<ZombieAI> _slipped = new();
static float? _barDepth;
/// <summary>How thick the bar is, across the direction it is laid. 40.</summary>
public static float BarDepth { get => _barDepth ?? 40f; set => _barDepth = value; }
static float? _slipSeconds;
/// <summary>
/// How long a slipping zombie is rooted. 2.2s.
///
/// ⚠️ MATCHED TO THE CLIPS, which run 75 and 81 frames — about 2.5s and 2.7s at 30fps. Slightly
/// under, so the zombie is up and moving again rather than lying still at the end of it.
/// </summary>
public static float SlipSeconds { get => _slipSeconds ?? 2.2f; set => _slipSeconds = value; }
/// <summary>
/// Throw one player upward.
///
/// ⛔ WRITTEN TO THE BODY, NOT TO THE CONTROLLER. `PlayerController.Velocity` is READ ONLY —
/// `Slide` documents the same thing and writes `Body.Velocity` for the same reason. This is a
/// one-frame impulse rather than sustained motion, so it does not have the race with the
/// controller's move step that a horizontal push would.
///
/// ⚠️ `WithZ`, NOT `+=`. Adding to the existing vertical speed means a player who steps on the
/// pad while already falling gets a smaller launch than one who walks on flat — the same pad
/// behaving differently for reasons the player cannot see. Replacing it makes every launch the
/// same height.
/// </summary>
void Launch( NZPlayer player )
{
var c = player.Components.Get<PlayerController>(
FindMode.EverythingInSelfAndDescendants );
var body = c.IsValid() ? c.Body : null;
if ( !body.IsValid() ) return;
// ⛔ LIFTED OFF THE FLOOR BEFORE THE IMPULSE, AND THIS IS THE WHOLE FIX. Setting the
// velocity alone worked only if you were ALREADY airborne — jumping onto the pad flung you
// correctly, walking onto it nudged you forward slightly and nothing else. While the
// controller considers you grounded it cancels upward velocity and re-snaps you to the
// floor, so only the horizontal remainder survived.
//
// ⚠️ AND IT HAS TO BE A POSITION NUDGE, because neither clean route exists on this engine
// version — both were tried and both failed to compile:
// `c.Punch( Vector3.Up * speed )` — no such method on PlayerController
// `c.IsOnGround = false` — read only
//
// ⚠️ SMALL ON PURPOSE. Enough to break contact, not enough to push anyone through a low
// ceiling; the launch itself is what gains the height.
player.WorldPosition += Vector3.Up * GroundBreak;
body.Velocity = body.Velocity.WithZ( LaunchSpeed );
Log.Info( $"[nz-place] springboard launched {player.GameObject.Name}"
+ $" at {LaunchSpeed:0}u/s — {Left - 1}/{Total} use(s) left" );
}
static float? _groundBreak;
/// <summary>
/// How far a launched player is lifted to break contact with the floor. 12.
///
/// ⚠️ TUNABLE BECAUSE IT IS A FIGHT WITH THE CONTROLLER'S GROUND CHECK, not a physical quantity
/// — if a future engine version snaps harder this is the number that has to grow.
/// </summary>
public static float GroundBreak { get => _groundBreak ?? 12f; set => _groundBreak = value; }
static float? _padHeight;
/// <summary>
/// How far above and below the pad a player still counts as on it. 72.
///
/// ⚠️ A PLAYER IS ~72 UNITS TALL and `WorldPosition` is at their feet, so this is deliberately
/// forgiving: it covers standing on a slope or a small step without reaching a floor above.
/// </summary>
public static float PadHeight { get => _padHeight ?? 72f; set => _padHeight = value; }
/// <summary>
/// Spend uses. Returns true when this was the hit that killed it.
///
/// ⚠️ EVERY KIND SPENDS THROUGH HERE so the m5 refund, the logging and the death have one author.
/// A zombie crossing the bar, a swing landing on the wall or the stand, and a launch off the pad
/// are all one `Use( 1 )`.
/// </summary>
public bool Use( int n = 1 )
{
if ( _died ) return false;
Left = Math.Max( 0, Left - Math.Max( 1, n ) );
if ( Left > 0 ) return false;
// ⚠️ THE REFUND IS PAID FROM `OnDestroy`, NOT HERE, so an object destroyed by any route pays
// exactly once and by one rule. This method only decides that it is over.
Log.Info( $"[nz-place] {Kind} used up — {Dies:0.#}s of {Life:0.#}s left" );
// ⚠️ DURABILITY IS SPENT ON THE HOST AND NOWHERE ELSE, because only the host runs the
// zombie code that chews through one. So this death has to be told to everybody — unlike
// the EXPIRY in `OnUpdate`, which every machine reaches on its own from the same `Life`.
if ( Networking.IsActive && Connection.Local is not null )
NZNet.PlaceableGone( Connection.Local.Id.ToString(), NetId );
GameObject?.Destroy();
return true;
}
protected override void OnDestroy()
{
// ⛔ ABOVE BOTH RETURNS BELOW, AND THAT PLACEMENT IS THE WHOLE POINT. The `_died` gate and
// the owner gate are about paying m5's refund ONCE, on ONE machine — but the horde has to be
// let go on EVERY machine, and whether the stand expired or was chewed through makes no
// difference to a zombie holding a reference to it. Put below either return this would fire
// on one machine and only for one of the two ways a stand can end. INSTRUCTIONS §4.
//
// ⚠️ ONLY MATTERS NOW THAT THE LURE IS ABSOLUTE. While the stand was one candidate among
// many, losing it left a zombie with a player still in its list; now a whole horde can be
// holding the stand and nothing else, and they all lose their target in the same frame.
if ( Kind == PlaceKind.Stand ) BananaStand.ReleaseNearby( this );
// ⛔ THE ONE PLACE m5 PAYS OUT, AND `_died` IS THE GATE. Set on expiry and on eviction, clear
// only when durability actually ran out — so a placeable that timed out or was pushed off the
// cap pays nothing, and one that was chewed through pays for the time it never got to use.
if ( _died ) return;
// ⛔ m5 PAYS ONCE, ON THE OWNER'S OWN MACHINE. Every machine now holds a copy and every
// copy reaches this line when the placeable dies — so without the gate an N-player game
// refunds N times, and N-1 of those refunds are written onto a proxy nobody reads.
if ( Networking.IsActive && Owner.IsValid()
&& !PlayerPresence.Mine( Owner.GameObject ) ) return;
BananaAugments.RefundOnBreak( Owner, Dies, Life );
}
// ══ the look ═════════════════════════════════════════════════════════════
/// <summary>
/// Draw the footprint: a floor outline and a glow, both light yellow.
///
/// ⚠️ SHAPE IS THE ONLY THING THAT TELLS THE KINDS APART, since all four share one colour by
/// request. A long thin bar, a tall panel, a wide ring and a small square are distinguishable at
/// a glance in a way four yellow boxes would not be.
/// </summary>
void BuildLook()
{
var lit = Colour.WithAlpha( Alpha );
var lightGo = new GameObject
{
Parent = GameObject,
Name = "nz_place_glow",
LocalPosition = Vector3.Up * 8f,
};
_light = lightGo.Components.Create<PointLight>();
_light.LightColor = Colour * 0.9f;
_light.Radius = Size * 1.1f;
switch ( Kind )
{
// ⚠️ A BAR IS DRAWN ACROSS THE PLAYER'S FACING, not along it. You place it in a doorway
// or across a corridor to be crossed — a bar pointing away from you is one nothing walks
// over.
case PlaceKind.SlickBar:
AddLine( Strip( WorldRotation.Right, Size, 14f ), lit, 1.2f );
break;
// ⚠️ THE WALL GETS A FLOOR FOOTPRINT *AND* UPRIGHTS, because a wall you cannot see the
// top of reads as a line on the ground. Four verticals at the corners is the cheapest
// thing that says "this is tall".
case PlaceKind.Wall:
AddLine( Strip( WorldRotation.Right, Size, 10f ), lit, 1.2f );
AddLine( Uprights( WorldRotation.Right, Size, 84f ), lit, 0.9f );
break;
// ⚠️ THE STAND DRAWS ITS ATTRACTION RADIUS, which is 900u and therefore huge. That is
// deliberate: the whole point of the bait is knowing what it will pull, and a small icon
// with an invisible reach would make it unplannable.
case PlaceKind.Stand:
AddLine( Ring( Size, 40 ), lit, 0.7f );
AddLine( Ring( 40f, 16 ), lit, 1.1f );
break;
case PlaceKind.Springboard:
AddLine( Ring( Size, 20 ), lit, 1.3f );
break;
}
}
LineRenderer AddLine( List<Vector3> pts, Color colour, float width )
{
var line = GameObject.Components.Create<LineRenderer>();
line.UseVectorPoints = true;
line.Additive = true;
line.Lighting = false;
line.CastShadows = false;
line.Color = colour;
// ⛔ `Width` IS A CURVE AND ITS UNITS ARE NOT WORLD UNITS — the values here are eyeballed,
// like `PitVisual`'s. 1.4 on `LightningArc` drew a ribbon before it was cut to 0.22.
line.Width = width;
line.VectorPoints = pts;
_lines.Add( line );
return line;
}
/// <summary>A closed rectangle on the floor, `length` across and `depth` deep.</summary>
List<Vector3> Strip( Vector3 across, float length, float depth )
{
var a = across.WithZ( 0f ).Normal;
var b = Vector3.Cross( a, Vector3.Up ).Normal;
var at = WorldPosition;
var half = length * 0.5f;
var d = depth * 0.5f;
// ⚠️ CLOSED — the last point repeats the first, because `LineRenderer` draws a polyline and
// not a loop. Every ring in this project has to do this; `ShockRing` and `PitVisual` both
// document it.
return new List<Vector3>
{
Floor( at - a * half - b * d ),
Floor( at + a * half - b * d ),
Floor( at + a * half + b * d ),
Floor( at - a * half + b * d ),
Floor( at - a * half - b * d ),
};
}
/// <summary>Corner posts, drawn as one zig-zag polyline so it costs one renderer.</summary>
List<Vector3> Uprights( Vector3 across, float length, float height )
{
var a = across.WithZ( 0f ).Normal;
var at = WorldPosition;
var half = length * 0.5f;
var l = Floor( at - a * half );
var r = Floor( at + a * half );
return new List<Vector3>
{
l, l + Vector3.Up * height,
r + Vector3.Up * height, r,
};
}
/// <summary>A closed circle on the floor.</summary>
List<Vector3> Ring( float radius, int segments )
{
var n = Math.Max( 6, segments );
var pts = new List<Vector3>( n + 1 );
var at = WorldPosition;
for ( var i = 0; i <= n; i++ )
{
var ang = i / (float)n * MathF.PI * 2f;
pts.Add( Floor( at + new Vector3( MathF.Cos( ang ), MathF.Sin( ang ), 0f ) * radius ) );
}
return pts;
}
/// <summary>
/// Put a point on the ground, 2 units clear.
///
/// ⚠️ 2u OFF THE SURFACE because a line exactly on the floor z-fights and flickers — the same
/// number and the same reason as every ring in `PitVisual` and `ShockRing`.
///
/// ⚠️ AND CLAMPED LIKE `PitVisual.MaxStep` DOES, so a footprint next to a crate does not walk up
/// the side of it. Reusing that constant rather than picking a second one.
/// </summary>
Vector3 Floor( Vector3 p )
{
var g = PitVisual.GroundAt( p );
if ( MathF.Abs( g.z - WorldPosition.z ) > PitVisual.MaxStep )
g.z = WorldPosition.z;
return g + Vector3.Up * 2f;
}
/// <summary>
/// Dim as it runs out — of time and of uses.
///
/// ⛔ THE LOWER OF THE TWO, NOT THE PRODUCT. A placeable is about to die when EITHER runs out, so
/// multiplying them would show something with one use left but plenty of time as still bright.
/// The player needs to know it is nearly gone, not which of the two clocks is closer.
/// </summary>
void Fade()
{
var byTime = Life <= 0f ? 1f : Math.Clamp( (float)Dies / Life, 0f, 1f );
var byUse = Total <= 0 ? 1f : Math.Clamp( Left / (float)Total, 0f, 1f );
// ⚠️ FLOORED AT 0.35 so a nearly-dead placeable is still visible. Fading to nothing would
// leave an object that still works and cannot be seen, which is worse than one that looks
// healthier than it is.
var k = 0.35f + 0.65f * MathF.Min( byTime, byUse );
var flick = 0.9f + 0.1f * MathF.Sin( Time.Now * 6f );
if ( _light.IsValid() )
{
_light.LightColor = Colour * 0.9f * k * flick;
_light.Radius = Size * 1.1f;
}
foreach ( var line in _lines )
if ( line.IsValid() )
line.Color = Colour.WithAlpha( Alpha * k );
}
}