Static helper for the Banana Colada perk. Defines tuning values, resolves which placeable kind a player has, awards charge on zombie kills, enforces placement rules, handles refunds when a placeable is broken, and provides console commands for diagnostics and live tuning.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Banana Colada's augments. Base perk: slides last ×1.8 as long, with a forgiving 1.5s window to
/// chain the next one.
///
/// | | effect |
/// |---|---|
/// | M1 **Slick Bar** | a bar on the floor; zombies crossing it slip |
/// | M2 **One-Way Wall** | you walk through it, they have to break it |
/// | M3 **Banana Stand** | the horde goes for it instead of you |
/// | M4 **Springboard** | a pad that flings you |
/// | m1 **Sticky Fingers** | kills recharge **+25%** |
/// | m2 **Big Bunch** | footprint **+50%** |
/// | m3 **Tough Peel** | durability **+20%** |
/// | m4 **Long Shelf Life** | **double** the time limit |
/// | m5 **Nothing Wasted** | broken early, unspent time comes back as charge (up to **50%**) |
///
/// ⛔ ALL FOUR MAJORS ARE PLACEABLES, WHICH IS THE PERK'S WHOLE IDENTITY AND A DELIBERATE REDESIGN.
/// The ported pool was nine slide/dive/low-gravity augments, most of which needed a dive system that
/// does not exist, and PhD Flopper already owns vertical mobility (m4 jump height, m5 double jump).
/// Re-specified by request around the one thing nothing else in the project does: letting the player
/// put an object into the world that zombies have to deal with.
///
/// ⚠️ AND THE FOUR ARE FOUR DIFFERENT *CATEGORIES*, not one idea printed four times — debuff, block,
/// redirect, propel. That spread is what stops the perk collapsing into "place a thing" with cosmetic
/// variations, and it is worth preserving if any of them is ever retuned.
///
/// ⛔ THE FIVE MINORS ALL SCALE THE SHARED SPINE rather than adding behaviour, so every one of them
/// works on whichever major you took. That is why `Placeable` has exactly four numbers — charge,
/// durability, lifetime, size — and the minors are one multiplier each.
///
/// ⚠️ m4 HELPS m5 AND m3 FIGHTS IT, which is a real tension and not a bug. m5 refunds a FRACTION of
/// the lifetime, so doubling the lifetime (m4) makes the same wall-clock death a bigger fraction and
/// a bigger refund; raising durability (m3) makes it survive longer, so less time is left when it
/// finally breaks. Worth knowing before either is retuned.
/// </summary>
public static class BananaAugments
{
const string Perk = "banana";
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
// re-run. INSTRUCTIONS.md §1.
static float? _chargePerKill;
/// <summary>
/// Charge earned per kill, as a fraction of a full placement. 0.02 — 50 kills (40 with Sticky Fingers).
///
/// ⛔ WAS 0.08 (13 KILLS) UNTIL 2026-10-03, FOUR TIMES FASTER. In the round-88 game a late horde refilled it in seconds:
/// 60 Banana Stands in 49 minutes, one every ~22 s, each pulling the whole live horde off the player. The user: *"players
/// hardly need to kill to recharge the bar, i need the recharge to be 4x slower"*. Nothing Wasted's refund (m5) is a share
/// of a full charge, so it is unaffected.
///
/// ⚠️ EXPRESSED AS A SHARE OF ONE PLACEMENT rather than as points, so the minors are plain
/// multipliers and the HUD can draw it as a bar without knowing a scale.
/// </summary>
public static float ChargePerKill
{
get => _chargePerKill ?? 0.02f;
set => _chargePerKill = value;
}
static float? _stickyFingers;
/// <summary>m1 Sticky Fingers — charge-per-kill multiplier. ×1.25.</summary>
public static float StickyFingers
{
get => _stickyFingers ?? 1.25f;
set => _stickyFingers = value;
}
static float? _bigBunch;
/// <summary>m2 Big Bunch — footprint multiplier. ×1.5.</summary>
public static float BigBunch { get => _bigBunch ?? 1.5f; set => _bigBunch = value; }
static float? _toughPeel;
/// <summary>m3 Tough Peel — durability multiplier. ×1.2.</summary>
public static float ToughPeel { get => _toughPeel ?? 1.2f; set => _toughPeel = value; }
static float? _shelfLife;
/// <summary>m4 Long Shelf Life — lifetime multiplier. ×2.</summary>
public static float ShelfLife { get => _shelfLife ?? 2f; set => _shelfLife = value; }
static float? _refundCap;
/// <summary>
/// m5 Nothing Wasted — the most of a placement that can come back. 0.5.
///
/// ⚠️ THIS IS THE CEILING, NOT THE PAYOUT. The refund is `timeLeftFraction × this`, so half the
/// time left pays 25% and a placeable destroyed the instant it lands pays the full 50%. Which
/// means the augment rewards placing INTO a crowd rather than placing safely — deliberate.
/// </summary>
public static float RefundCap { get => _refundCap ?? 0.5f; set => _refundCap = value; }
static float? _placeAhead;
/// <summary>How far in front of the player a placeable lands. 90u.</summary>
public static float PlaceAhead { get => _placeAhead ?? 90f; set => _placeAhead = value; }
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
// ══ the minors, as resolvers the spine reads ══════════════════════════════
/// <summary>m1 — charge-per-kill multiplier. 1 when not held.</summary>
public static float ChargeScale( NZPlayer p )
=> Has( p, "m1" ) ? MathF.Max( 0f, StickyFingers ) : 1f;
/// <summary>m2 — footprint multiplier. 1 when not held.</summary>
public static float SizeScale( NZPlayer p )
=> Has( p, "m2" ) ? MathF.Max( 0.1f, BigBunch ) : 1f;
/// <summary>m3 — durability multiplier. 1 when not held.</summary>
public static float DurabilityScale( NZPlayer p )
=> Has( p, "m3" ) ? MathF.Max( 0.1f, ToughPeel ) : 1f;
/// <summary>m4 — lifetime multiplier. 1 when not held.</summary>
public static float LifeScale( NZPlayer p )
=> Has( p, "m4" ) ? MathF.Max( 0.1f, ShelfLife ) : 1f;
// ══ charge ════════════════════════════════════════════════════════════════
/// <summary>
/// Which placeable this player's equipped major grants, or null.
///
/// ⛔ FIRST MAJOR FOUND, AND THAT ONLY MATTERS IN CREATIVE. Normal play equips ONE major, so
/// there is exactly one answer; the Creative override lifts that limit, and rather than leave the
/// behaviour undefined the rule is written down here: M1 wins, then M2, then M3, then M4. The
/// same situation `DtapAugments` documents for its major pair.
/// </summary>
public static PlaceKind? KindFor( NZPlayer player )
{
if ( Has( player, "M1" ) ) return PlaceKind.SlickBar;
if ( Has( player, "M2" ) ) return PlaceKind.Wall;
if ( Has( player, "M3" ) ) return PlaceKind.Stand;
if ( Has( player, "M4" ) ) return PlaceKind.Springboard;
return null;
}
/// <summary>
/// A zombie died — bank charge toward the next placement.
///
/// ⛔ GATED ON HOLDING A MAJOR, NOT JUST THE PERK. Charge with nothing to spend it on is a bar
/// that fills and never empties, which reads as broken rather than as unused. A player on the
/// base perk alone banks nothing.
///
/// ⚠️ IT DOES NOT CARE WHICH MAJOR. One meter serves all four because only one is equipped —
/// see `KindFor` for the Creative case.
/// </summary>
public static void OnZombieKilled( GameObject killer, GameObject victim )
{
if ( !killer.IsValid() ) return;
var player = killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
if ( !player.IsValid() ) return;
if ( KindFor( player ) is null ) return;
// ⛔ NO CHARGE WHILE ONE OF YOURS IS OUT (2026-10-04, the user: *"No recharge while a placeable is out is a great
// idea"*). The 4x slower recharge alone left the round-88 rhythm where it was: at the late kill rate its 40 kills take
// about 22 s, the old gap between Banana Stands. Now the next one only starts to charge once the last one is gone.
//
// ⚠️ SO A SECOND ONE OF THE SAME KIND CANNOT BE BANKED any more, and the cap of 2 on the bar and the pad is out of
// reach: placing spends the whole charge, and none comes back until the floor is clear. m5's refund is not a kill
// and still pays, on a placeable that has just gone.
if ( HasOneOut( player ) ) return;
var was = player.PlaceCharge;
player.PlaceCharge = MathF.Min( 1f,
was + MathF.Max( 0f, ChargePerKill ) * ChargeScale( player ) );
// ⚠️ LOGGED ONLY ON REACHING FULL, not on every kill. A line per kill would bury the console
// in a horde, and "it is ready" is the only moment the player needs told about.
if ( was < 1f && player.PlaceCharge >= 1f )
Log.Info( $"[nz-aug] banana — {KindFor( player )} ready to place" );
}
/// <summary>
/// Does this player have a placeable in the world right now — the one condition that stops kills charging the next.
///
/// ⚠️ ASKED ON THE KILLER'S OWN MACHINE, which is where the charge lives (`AugmentEffects.OnZombieKilled` relays), and
/// every machine builds its own copy of every placeable with its `Owner` set (`Placeable.Spawn`), so the answer is there.
/// </summary>
public static bool HasOneOut( NZPlayer player )
{
if ( !player.IsValid() ) return false;
foreach ( var p in Placeable.All )
if ( p.Owner == player ) return true;
return false;
}
/// <summary>
/// m5 — pay back unspent time as charge, when a placeable was BROKEN rather than expired.
///
/// ⛔ `Placeable.OnDestroy` DECIDES WHETHER TO CALL THIS, and this method trusts it. The gate is
/// there because three things destroy a placeable — durability, the clock, and being evicted at
/// the cap — and only the first should pay. Putting the gate here as well would be two authors
/// for one rule (§3); putting it only here would mean a cap-eviction refund, which is a charge
/// farm: place, place, place at the cap and get paid each time.
/// </summary>
public static void RefundOnBreak( NZPlayer player, float timeLeft, float life )
{
if ( !Has( player, "m5" ) ) return;
if ( life <= 0f ) return;
var fraction = Math.Clamp( timeLeft / life, 0f, 1f );
var back = fraction * MathF.Max( 0f, RefundCap );
if ( back <= 0.001f ) return;
player.PlaceCharge = MathF.Min( 1f, player.PlaceCharge + back );
Log.Info( $"[nz-aug] banana m5 Nothing Wasted — {fraction * 100f:0}% of the time left"
+ $" → {back * 100f:0}% charge back (cap {RefundCap * 100f:0}%)"
+ $" · now {player.PlaceCharge * 100f:0}%" );
}
// ══ placing ═══════════════════════════════════════════════════════════════
/// <summary>
/// Why this player cannot place right now, or null when they can.
///
/// ⚠️ A REASON RATHER THAN A BOOL, the convention `Armor.WhyCannotPlate` set for exactly this
/// job — a key that "did nothing" must always be able to say why. Every refusal below is a
/// different thing for the player to fix.
/// </summary>
public static string WhyCannotPlace( NZPlayer player )
{
if ( !player.IsValid() ) return "no player";
if ( !player.HasPerk( Perk ) ) return "you do not have Banana Colada";
if ( KindFor( player ) is null ) return "no major augment equipped — nothing to place";
if ( player.PlaceCharge < 1f )
return HasOneOut( player )
? $"charge {player.PlaceCharge * 100f:0}% — paused while yours is still out"
: $"charge {player.PlaceCharge * 100f:0}% — kill zombies to fill it";
return null;
}
/// <summary>
/// Put one down in front of the player and spend the charge.
///
/// ⚠️ FLATTENED WITH `WithZ( 0 )` so looking at the sky still puts it on the floor ahead of you.
/// `Placeable.Spawn` traces down 256u, and a point thrown into the air would miss entirely.
/// </summary>
public static bool TryPlace( NZPlayer player )
{
var why = WhyCannotPlace( player );
if ( why is not null )
{
Log.Info( $"[nz-aug] banana — cannot place: {why}" );
return false;
}
var kind = KindFor( player ).Value;
var fwd = player.EyeAngles.Forward.WithZ( 0f ).Normal;
var at = player.WorldPosition + fwd * MathF.Max( 0f, PlaceAhead );
var p = Placeable.Spawn( player, kind, at );
if ( !p.IsValid() ) return false;
player.PlaceCharge = 0f;
// ⛔ NEARBY ZOMBIES ARE PUSHED TO RE-ACQUIRE, AND ONLY FOR THE STAND. `AcquireTarget` runs on
// a 3-15s cadence, so a lure placed to save you would take up to fifteen seconds to work —
// which is not a lure, it is a decoration. `ForceRetarget` exists as a public push documented
// for exactly this from-outside case.
//
// ⚠️ THE OTHER THREE MUST *NOT* DO THIS. A bar, a wall and a pad are things zombies walk into
// while chasing you; making them retarget would be telling the horde to look at a wall.
if ( kind == PlaceKind.Stand ) BananaStand.PullNearby( p );
Log.Info( $"[nz-aug] banana — placed {kind} at {p.WorldPosition}"
+ $" · {p.Total} use(s), {p.Life:0.#}s, {p.Size:0}u"
+ $" ({Placeable.SpecFor( kind ).SizeNote})" );
return true;
}
// ══ diagnostics ═══════════════════════════════════════════════════════════
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>
/// `nz_aug_banana_place` — place one, ignoring the key bind.
///
/// ⛔ IT STILL SPENDS THE CHARGE AND STILL REFUSES, because a test command that bypasses the
/// economy tests something the game never does. `nz_aug_banana_charge 1` is the way to skip the
/// grind; this is the way to exercise the real path.
///
/// ⛔ RENAMED FROM `nz_place`, WHICH IT WAS STEALING FROM THE MAP EDITOR. Two files registered
/// that name and s&box keeps the FIRST — it logs "Command nz_place already exists - not
/// overwriting" and carries on — so `nz_place` with a build tool armed answered
/// "you do not have Banana Colada" instead of placing anything. A generic name on a
/// perk-specific test command; the editor's is the one that deserves it.
///
/// ⚠️ A COMMAND NAME IS CLAIMED FOR THE LIFE OF THE EDITOR SESSION, so this rename does not
/// take effect until s&box is restarted. Same trap `nz_stats` -> `nz_score` hit.
/// </summary>
[ConCmd( "nz_aug_banana_place" )]
public static void PlaceCmd() => TryPlace( Me() );
/// <summary>`nz_aug_banana_charge [0-1]` — set the charge meter.</summary>
[ConCmd( "nz_aug_banana_charge" )]
public static void ChargeCmd( float value = 1f )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
p.PlaceCharge = Math.Clamp( value, 0f, 1f );
Log.Info( $"[nz-aug] banana charge = {p.PlaceCharge * 100f:0}%" );
}
/// <summary>`nz_aug_banana` — every resolved number, and what is on the floor.</summary>
[ConCmd( "nz_aug_banana" )]
public static void BananaCmd()
{
var player = Me();
if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var has = player.HasPerk( Perk );
var equipped = PerkAugments.EquippedOn( player, Perk );
var kind = KindFor( player );
Log.Info( $"[nz-aug] BANANA COLADA {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]"
+ $" · slide x{PerkEffects.BananaSlideDuration:0.##}" );
Log.Info( $"[nz-aug] placing {(kind is null ? "NOTHING — no major equipped" : kind.ToString())}"
+ $" · charge {player.PlaceCharge * 100f:0}%{(HasOneOut( player ) ? " (PAUSED — one is out)" : "")}"
+ $" · {ChargePerKill * ChargeScale( player ) * 100f:0.#}% per kill"
+ $" (~{MathF.Ceiling( 1f / MathF.Max( 0.001f, ChargePerKill * ChargeScale( player ) )):0} kills)" );
// ⚠️ THE RESOLVED SPEC IS SHOWN AGAINST THE AUTHORED ONE, because every minor is a multiplier
// and "x1.5" says nothing about whether the result is sensible.
if ( kind is not null )
{
var spec = Placeable.SpecFor( kind.Value );
Log.Info( $"[nz-aug] durability {spec.Durability}"
+ $" -> {Math.Max( 1, (int)MathF.Round( spec.Durability * DurabilityScale( player ) ) )}"
+ $" life {spec.Seconds:0.#}s -> {spec.Seconds * LifeScale( player ):0.#}s"
+ $" {spec.SizeNote} {spec.Size:0}u -> {spec.Size * SizeScale( player ):0}u"
+ $" cap {spec.Cap}" );
}
Log.Info( $"[nz-aug] m1 Sticky {(Has( player, "m1" ) ? $"charge x{StickyFingers:0.##}" : "-")}"
+ $" m2 Bunch {(Has( player, "m2" ) ? $"size x{BigBunch:0.##}" : "-")}"
+ $" m3 Peel {(Has( player, "m3" ) ? $"dura x{ToughPeel:0.##}" : "-")}"
+ $" m4 Shelf {(Has( player, "m4" ) ? $"life x{ShelfLife:0.##}" : "-")}" );
// ⚠️ THE REFUND IS SHOWN AS A WORKED EXAMPLE, because "up to 50%" is the part people misread
// — it is a fraction OF a fraction, not a flat 50%.
Log.Info( $"[nz-aug] m5 Wasted {(Has( player, "m5" ) ? $"up to {RefundCap * 100f:0}%" : "-")}"
+ $" worked: half the time left -> {0.5f * RefundCap * 100f:0}% back"
+ $" · broken instantly -> {RefundCap * 100f:0}%"
+ " · expired -> 0%" );
var live = Placeable.All.ToList();
Log.Info( $"[nz-aug] on the floor {live.Count}" );
foreach ( var p in live )
Log.Info( $"[nz-aug] {p.Kind,-12} {p.Left}/{p.Total} use(s)"
+ $" · {(float)p.Dies:0.#}s of {p.Life:0.#}s left"
+ $" · {p.Size:0}u · at {p.WorldPosition}" );
}
/// <summary>`nz_aug_banana_set <key> <value>` — retune one number live.</summary>
[ConCmd( "nz_aug_banana_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "perkill": ChargePerKill = value; break;
case "sticky": StickyFingers = value; break;
case "bunch": BigBunch = value; break;
case "peel": ToughPeel = value; break;
case "shelf": ShelfLife = value; break;
case "refund": RefundCap = value; break;
case "ahead": PlaceAhead = value; break;
default:
Log.Info( "[nz-aug] nz_aug_banana_set <perkill|sticky|bunch|peel|shelf"
+ "|refund|ahead> <value>" );
Log.Info( "[nz-aug] per-kind durability/life/size: literals in Placeable.SpecFor" );
return;
}
Log.Info( $"[nz-aug] banana {key} = {value:0.###}" );
BananaCmd();
}
}