Player/Placeable.cs

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.

NetworkingFile Access
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 );
	}
}