Powerup component and related utilities. Manages spawning, rendering (glow, spin, blink), lifetime, pickup logic, networked spawn/collect broadcasts, HUD/announcer cues, and admin console commands; also defines PowerupKind enum.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// A powerup lying on the floor: the shell every one of them shares.
///
/// ⛔ NO EFFECTS HERE, DELIBERATELY. Spawn, float, spin, expire and collect are
/// identical for Max Ammo, Instakill, Double Points, Nuke and Carpenter — only what
/// happens ON COLLECT differs. Building the common half first means the effects are
/// each a single method rather than five copies of a lifetime.
///
/// Rules are the original's, from `entities/drop_powerup/shared.lua`:
/// - 30s on the floor (`SetKillTime(CurTime() + 30)`)
/// - blinks the last 5s (`SetBlinkTime(CurTime() + 25)`)
/// - a wobbling spin, NOT a constant yaw (`Angle(2,50,5) * sin(CurTime()/10)`)
/// - a short delay before it can be taken (`antipowerupdelay`, 1-4s)
/// </summary>
public sealed class Powerup : Component
{
/// <summary>Which powerup this is. Nothing acts on it yet.</summary>
[Property] public PowerupKind Kind { get; set; } = PowerupKind.MaxAmmo;
/// <summary>Seconds on the floor before it disappears. The original's 30.</summary>
[Property] public float Lifetime { get; set; } = 30f;
/// <summary>
/// Points this powerup pays, overriding the usual roll. Negative means "roll normally".
///
/// ⛔ ON THE INSTANCE, NOT PASSED AT PICKUP. A dropped powerup is worth what the dropper paid
/// for it, and that number has to survive from the drop until whoever finds it walks over it —
/// possibly a minute later, possibly a different player. Nothing at the pickup site knows it,
/// so the powerup carries it.
///
/// ⚠️ Only BonusPoints reads it. Every other kind ignores it, which is why it is one loose
/// field rather than a payload type: a Max Ammo with a points value would be a lie.
/// </summary>
[Property] public int PointsOverride { get; set; } = -1;
/// <summary>
/// Seconds of blinking before it goes.
///
/// ⚠️ FIVE, from the original's 25s blink against a 30s life. The blink is the
/// only warning a player gets, and it is what turns a powerup from something you
/// wander into to something you decide to run for.
/// </summary>
[Property] public float BlinkFor { get; set; } = 5f;
/// <summary>
/// How long before it can be picked up.
///
/// ⛔ NOT ZERO. The original delays every drop (`antipowerupdelay`, 4s, or a
/// random 1-3) because a powerup spawns ON a zombie you are standing next to —
/// without the delay you collect it before you have seen it, and a Max Ammo you
/// never noticed is a Max Ammo you did not get to enjoy.
/// </summary>
[Property] public float ArmDelay { get; set; } = 1.5f;
/// <summary>How close you must be to take it.</summary>
[Property] public float PickupRadius { get; set; } = 48f;
/// <summary>
/// Height above where it dropped.
///
/// ⚠️ 40, not 20. These models are authored with their origin at the BASE, so at
/// 20 the box sits on the floor rather than hovering — it read as dropped litter
/// instead of a pickup.
/// </summary>
[Property] public float Hover { get; set; } = 40f;
/// <summary>
/// The glow around it.
///
/// ⛔ A SPRITE, NOT A LIGHT. A `PointLight` small enough not to wash the room
/// still throws colour onto the floor under it, and a large one lights the whole
/// area — neither is what a powerup does. Described exactly: "a green ball of
/// light with a very small radius, it englobes the powerup but does not illuminate
/// the surroundings". An additive billboard IS only itself: it adds light to its
/// own pixels and touches nothing else in the scene.
/// </summary>
[Property] public Color GlowColor { get; set; } = new( 0.25f, 1f, 0.3f, 1f );
/// <summary>How big the glow is, in world units.</summary>
[Property] public float GlowSize { get; set; } = 90f;
SpriteRenderer _glow;
TimeSince _alive;
ModelRenderer _renderer;
bool _taken;
/// <summary>Has it been collected?</summary>
public bool Taken => _taken;
/// <summary>Seconds left before it expires.</summary>
public float Remaining => System.MathF.Max( 0f, Lifetime - _alive );
/// <summary>Is it in its final flashing seconds?</summary>
public bool Blinking => Remaining <= BlinkFor;
SoundHandle _hum;
protected override void OnStart()
{
_alive = 0f;
_renderer = Components.Get<ModelRenderer>( FindMode.EverythingInSelfAndDescendants );
StartHum();
}
/// <summary>
/// The ambient hum while it lies there.
///
/// ⛔ THE HANDLE IS KEPT, because this is the one sound in the game that MUST be
/// stopped by hand. Everything else is a one-shot that ends on its own; a looping
/// cue whose handle is dropped plays until the map unloads, and the powerup it
/// belonged to expired thirty seconds ago. The mystery box jingle needed exactly
/// this and for exactly this reason.
/// </summary>
/// ⚠️ `PlayAmbient`, NOT `Play` — it culls out of earshot. A looping cue started
/// with the plain call is audible to everyone regardless of distance AND prints a
/// line per repeat with the audio trace on, which buried every other cue in the
/// console when Pack-a-Punch did it.
void StartHum()
{
// ⛔ NEVER RESTART IT ONCE TAKEN. The re-establish runs from OnUpdate, and on
// the frame a powerup is collected the hum is stopped and then immediately
// started again by the same tick — audible as a blip of ambience AFTER the
// pickup, and visible in the trace as `nz.powerup.loop (#2)` landing a line
// below "collected".
if ( _taken ) return;
if ( !_hum.IsValid() )
_hum = NZSound.PlayAmbient( NZSound.PowerupLoop, WorldPosition );
}
/// <summary>
/// Stop the hum. Safe to call twice.
///
/// ⚠️ CALLED FROM BOTH EXITS — collected and expired. A stop on only one of them
/// leaves the hum playing forever down whichever path was forgotten, and that is
/// the path nobody tests.
/// </summary>
void StopHum()
{
_hum?.Stop();
_hum = null;
}
protected override void OnDestroy() => StopHum();
protected override void OnUpdate()
{
if ( _taken ) return;
if ( _alive >= Lifetime )
{
Log.Info( $"[nz] powerup {Kind} expired" );
GameObject.Destroy();
return;
}
Spin();
Blink();
TryCollect();
// ⚠️ RE-ESTABLISHED WHEN IT ENDS, the same way Pack-a-Punch keeps its hum
// alive — the ambient is a finite clip, so "start it once" gives you one
// play and then silence for the remaining 28 seconds.
StartHum();
// ⚠️ And it FOLLOWS. The powerup does not move today, but it is dropped by a
// dying zombie, and putting one on a slope or a moving platform later would
// otherwise leave the sound behind at the spawn point.
if ( _hum.IsValid() ) _hum.Position = WorldPosition;
}
/// <summary>
/// The wobble.
///
/// ⛔ NOT A CONSTANT YAW. The original turns by `Angle(2,50,5) * sin(CurTime()/10)`
/// per frame — the sine makes the whole rotation speed up, slow, stop and REVERSE
/// over about a minute, and the uneven axes make it tumble rather than turn on the
/// spot. A flat spin reads as a pickup in any other game; this reads as theirs.
/// </summary>
void Spin()
{
var s = System.MathF.Sin( Time.Now / 10f ) * Time.Delta;
WorldRotation *= Rotation.From( 2f * s, 50f * s, 5f * s );
}
/// <summary>
/// Flash out the last seconds.
///
/// ⚠️ TOGGLES THE RENDERER, not the object. Disabling the GameObject would stop
/// `OnUpdate` — the powerup would freeze on its first blink and never expire or be
/// collectable again.
/// </summary>
void Blink()
{
if ( !_renderer.IsValid() ) return;
if ( !Blinking )
{
_renderer.Enabled = true;
if ( _glow.IsValid() ) _glow.Enabled = true;
return;
}
// ⚠️ Accelerating: slow at five seconds out, frantic at the end. A constant
// flash tells you it is leaving but not WHEN.
var urgency = 1f - (Remaining / System.MathF.Max( BlinkFor, 0.01f ));
var rate = MathX.Lerp( 4f, 14f, urgency );
// ⚠️ THE GLOW BLINKS WITH THE MODEL. Leaving it lit would hang a green ball in
// the air with nothing inside it on every off-frame, which reads as a separate
// effect rather than as the powerup flashing.
var on = System.MathF.Sin( Time.Now * rate ) > 0f;
_renderer.Enabled = on;
if ( _glow.IsValid() ) _glow.Enabled = on;
}
void TryCollect()
{
// ⛔ THE HOST DECIDES WHO PICKED IT UP. Every machine runs this component, and two
// machines both deciding "collected" is two of every announcement, two banners, and a
// timed powerup started twice from two different clocks. The host collects and tells
// everybody; a client's copy is scenery until then.
if ( NZGame.IsClient ) return;
// ⚠️ Armed only after the delay — see ArmDelay.
if ( _alive < ArmDelay ) return;
foreach ( var player in Scene.GetAllComponents<NZPlayer>() )
{
if ( !player.IsValid() ) continue;
// ⚠️ Measured to the player's MIDDLE. Their origin is at the feet and the
// powerup hovers at knee height, so a foot-to-powerup distance makes the
// radius feel about a third smaller than it is.
var mid = player.WorldPosition + Vector3.Up * 32f;
if ( mid.Distance( WorldPosition ) > PickupRadius ) continue;
Collect( player );
return;
}
}
/// <summary>
/// Taken.
///
/// ⚠️ THE EFFECT GOES HERE AND NOWHERE ELSE — this is the seam the whole shell
/// exists to provide. Right now it only announces and vanishes.
/// </summary>
/// <summary>
/// The three sounds a powerup makes when it is taken.
///
/// ⛔ ONE AUTHOR FOR TWO CALLERS, AND THE SECOND ONE IS WHY THIS IS A METHOD. `Collect` runs
/// on the machine that picked it up; `CollectRemote` runs on all the others — and only the
/// first of them had the cues, so everyone else got a silent banner.
///
/// ⛔ THE PICKUP AND THE POWERUP'S OWN CUE ARE TWO SOUNDS, LAYERED. The pickup is the
/// physical grab and is the same for all of them; the second is WHICH powerup you got, and it
/// is the one that carries the information. Replacing the grab with it would lose the tactile
/// half. The third is the announcer saying it out loud.
///
/// ⚠️ A NULL POSITION MEANS "AT THE LISTENER", for a machine whose copy of the powerup has
/// already been cleaned up when the message lands.
/// </summary>
static void PlayCues( PowerupKind kind, Vector3? at )
{
void Say( string cue )
{
if ( string.IsNullOrEmpty( cue ) ) return;
if ( at.HasValue ) NZSound.Play( cue, at.Value );
else NZSound.Play( cue );
}
Say( NZSound.PowerupPickup );
Say( CueFor( kind ) );
Say( AnnouncerFor( kind ) );
}
/// <summary>
/// Drop a powerup from code that may be running on ANY machine.
///
/// ⛔ `Spawn` ONLY ANNOUNCES ITSELF FROM THE HOST, so a client calling it directly creates an
/// object that exists on one screen, cannot be picked up by anybody else, and never despawns
/// for them. That is exactly the bug the user hit with a bonus-points drop — *"if a client
/// spawns a bonus point using 5 it cannot be picked up and the host cannot see it"* — and
/// `NZNet.PointsDropAsk` was the one-off fix for that one caller.
///
/// ⚠️ IT BECAME GENERAL THE MOMENT A SECOND CALLER COULD RUN ON A CLIENT. Widow's Wine's M4
/// Spider's Gift drops a powerup on a KILL, and kill augments now run on the killer's own
/// machine — so the moment that was fixed, a client's spider became invisible to everyone else.
/// One more one-off would have been the third answer to one question.
///
/// ⚠️ RETURNS NOTHING ON A CLIENT, AND CALLERS MUST NOT READ THAT AS FAILURE. The drop is
/// real; it simply arrives a moment later through `PowerupDropped`, like every other powerup.
/// </summary>
public static void SpawnShared( Vector3 pos, PowerupKind kind, int pointsOverride = -1 )
{
if ( Networking.IsActive && NZGame.IsClient )
{
NZNet.PowerupSpawnAsk( pos, (int)kind, pointsOverride );
return;
}
Spawn( pos, kind, pointsOverride );
}
public void Collect( NZPlayer player )
{
if ( _taken ) return;
_taken = true;
StopHum();
PlayCues( Kind, WorldPosition );
PowerupBannerState.Show( PowerupBannerState.NameFor( Kind ) );
// ⚠️ EVERY MACHINE GETS THE BANNER, THE CUES AND THE TIMER — a powerup in this game is a
// TEAM event, not a personal one. Sent from the host's collect, and a client reaches this
// method only through `CollectRemote`, which does not announce again.
//
// ⚠️ AND WHO COLLECTED IT TRAVELS, because the INSTANT half is not a team event. Bonus
// Points pays one player; without this every machine paid its own, so a client's own drop
// paid it twice — once relayed from the host's `AddPoints`, once again locally.
// User: *"if a client spawns a bonus points and picks it up they pick up 2000 instead of
// 1000."*
if ( Networking.IsActive && NZGame.IsHost )
NZNet.PowerupTaken( NetId, (int)Kind, PointsOverride,
Guid.TryParse( NZPlayers.OwnerOf( player.GameObject ), out var g ) ? g
: Connection.Local?.Id ?? default );
// ⚠️ Starts the clock for the TIMED kinds only — `Activate` no-ops on the
// instant ones, so there is no condition to keep in sync here.
ActivePowerups.Activate( Kind );
// ⚠️ The instant effects fire here; the timed ones do nothing at pickup and
// are read live from the registry by whatever they affect.
//
// ⛔ BUT ONLY WHEN THE COLLECTOR IS *THIS* MACHINE'S PLAYER, AND THAT IS WHY THE DOUBLE
// PAYOUT SURVIVED THE LAST FIX. The host collects on behalf of a client's body, and
// `PowerupEffects.Apply` → `AddPoints` → **relays the award to the owner**. Then the
// broadcast lands and the owner's own `CollectRemote` applies it a second time. Moving the
// payout to the collector's machine, as the last build did, did not remove the double — it
// only changed which of the two machines was paying twice.
// User: *"client's point drop by pressing 5 still awards double the amount."*
//
// ⚠️ THE REMOTE COLLECTOR IS NOT SKIPPED, IT IS DEFERRED. `NZNet.PowerupTaken` carries the
// collector and the payout, and their own machine applies it — with their own perks and
// their own augments in the calculation, which the host's copy does not have.
// ⛔ MAX AMMO IS A TEAM EFFECT AND THIS BRANCH DID NOT KNOW IT — SO THE HOST WENT
// WITHOUT WHENEVER A CLIENT PICKED ONE UP. Reported as *"max ammo só deu a uma pessoa"*.
//
// `CollectRemote` has carried the exception since it was written; this path never got a
// copy, and the two halves only disagree in one direction:
//
// host collects -> Mine is true, host refills here · client refills via the
// broadcast -> BOTH, looks correct
// client collects -> pickup is host-authoritative, so `player` is the CLIENT'S body
// on the host and `Mine` is false · the host refills NOBODY
// and cannot recover, because
// `NZNet.PowerupTaken` opens with
// `if ( NZGame.IsHost ) return` -> only the client
//
// Which is why it reads as intermittent rather than broken: it depends on who walked
// over it.
//
// ⚠️ `NZPlayer.Local`, NOT `player`, AND THAT IS THE WHOLE POINT. `player` is whoever
// collected it — possibly someone else's body being driven by the host. Every machine
// refills the person sitting at it, exactly as `CollectRemote` does.
//
// ⚠️ NO DOUBLE-APPLY IS POSSIBLE HERE, unlike the payout this branch's own ⛔ block
// above is about. The host early-returns from its own broadcast, so a machine reaches the
// refill through exactly one of the two paths. And a refill is idempotent anyway: it
// assigns `MaxReserve` and a full clip rather than adding.
if ( PowerupEffects.IsTeamEffect( Kind ) )
PowerupEffects.Apply( Kind, NZPlayer.Local, PointsOverride );
else if ( !Networking.IsActive || PlayerPresence.Mine( player.GameObject ) )
PowerupEffects.Apply( Kind, player, PointsOverride );
Log.Info( $"[nz] powerup {Kind} collected" );
GameObject.Destroy();
}
// ── spawning ─────────────────────────────────────────────────────────────
/// <summary>
/// The cue that says WHICH powerup was taken.
///
/// ⚠️ Empty for the ones not yet imported, and `NZSound.Play` is skipped rather
/// than asked for a missing cue — a failing sound lookup logs per call, and this
/// one would fire on every pickup of every unfinished powerup.
/// </summary>
public static string CueFor( PowerupKind kind ) => kind switch
{
PowerupKind.MaxAmmo => NZSound.PowerupMaxAmmo,
PowerupKind.Nuke => NZSound.PowerupNuke,
// ⛔ NO PICKUP CUE — `firesale_jingle` is the LOOP that plays for the whole
// 30 seconds (see PowerupMusic), not a one-shot. Firing it here as well would
// start the tune twice, a beat apart.
// ⚠️ EMPTY IS CORRECT for the rest, not an omission. Double Points, Bonus
// Points and Carpenter have no pickup flux in the pack — what they own is a
// LOOP that plays while the effect runs, which belongs to the effect and not
// to the moment of collection.
_ => "",
};
/// <summary>
/// The announcer line for a powerup — the VOICE, separate from the effect cue.
///
/// ⚠️ THREE SOUNDS ON A PICKUP, not one: the grab (same for all), the effect's
/// own sound, and the announcer naming it. The original layers all three, and
/// each carries something the others do not — the grab is tactile, the flux says
/// ammo arrived, the voice says WHICH powerup to the whole team.
/// </summary>
public static string AnnouncerFor( PowerupKind kind ) => kind switch
{
PowerupKind.MaxAmmo => NZSound.AnnouncerMaxAmmo,
PowerupKind.DoublePoints => NZSound.AnnouncerDoublePoints,
PowerupKind.BonusPoints => NZSound.AnnouncerBonusPoints,
PowerupKind.Carpenter => NZSound.AnnouncerCarpenter,
PowerupKind.Nuke => NZSound.AnnouncerNuke,
PowerupKind.FireSale => NZSound.AnnouncerFireSale,
// ⚠️ Insta-Kill's line comes from a DIFFERENT pack to the other five — see
// NZSound.AnnouncerInstaKill.
PowerupKind.InstaKill => NZSound.AnnouncerInstaKill,
_ => "",
};
/// <summary>
/// The 2D HUD icon for a powerup.
///
/// ⚠️ The BO1 set from `nz_moo/powerup_icons/bo1` — the game's own HUD art, not a
/// render of the 3D model. The floor model and the icon are different assets on
/// purpose: one is lit and tumbling in the world, the other has to read at 40px.
/// </summary>
public static string IconFor( PowerupKind kind ) => kind switch
{
// ⚠ REUSES MAX AMMO'S ICON AND MODEL AS A PLACEHOLDER. No spider art exists in the
// asset system (checked: only `ui/perks/widowswine.png`), and an unresolvable path fails
// at LOAD with a green compile — §18 — so it would ship as an invisible power-up. Swap
// both when the art lands.
PowerupKind.Spider => "materials/nz/powerups/maxammo.png",
PowerupKind.MaxAmmo => "materials/nz/powerups/maxammo.png",
PowerupKind.InstaKill => "materials/nz/powerups/insta.png",
PowerupKind.DoublePoints => "materials/nz/powerups/dp.png",
PowerupKind.BonusPoints => "materials/nz/powerups/bonuspoints.png",
PowerupKind.Carpenter => "materials/nz/powerups/carpenter.png",
PowerupKind.Nuke => "materials/nz/powerups/nuke.png",
PowerupKind.FireSale => "materials/nz/powerups/firesale.png",
_ => "",
};
/// <summary>The soft round flare the glow is drawn with.</summary>
public const string GlowSprite = "sprites/nz/powerup_glow.sprite";
/// <summary>Where each kind's model lives.</summary>
public static string ModelFor( PowerupKind kind ) => kind switch
{
PowerupKind.Spider => "models/nz/powerups/maxammo.vmdl",
PowerupKind.MaxAmmo => "models/nz/powerups/maxammo.vmdl",
PowerupKind.InstaKill => "models/nz/powerups/insta.vmdl",
PowerupKind.DoublePoints => "models/nz/powerups/2x.vmdl",
PowerupKind.BonusPoints => "models/nz/powerups/bonus.vmdl",
PowerupKind.Carpenter => "models/nz/powerups/carpenter.vmdl",
PowerupKind.Nuke => "models/nz/powerups/nuke.vmdl",
PowerupKind.FireSale => "models/nz/powerups/firesale.vmdl",
// ⚠️ Falls back to the ammo can rather than returning null — an unported
// powerup then LOOKS wrong on the floor instead of failing to spawn, which is
// far easier to notice than a drop that silently does not happen.
_ => "models/nz/powerups/maxammo.vmdl",
};
/// <summary>
/// Drop one at a position.
///
/// ⚠️ LIFTED OFF THE FLOOR by <see cref="Hover"/>. A powerup dropped exactly where
/// a zombie died sits half inside the ground on any sloped surface, and the models
/// are authored with their origin at the base.
/// </summary>
/// <summary>
/// Which drop this is, shared across machines.
///
/// ⛔ A GUID RATHER THAN A POSITION. Two powerups can drop on the same spot within a second
/// of each other, and "the one near here" would then take the wrong one away. The host makes
/// the id when it makes the drop and every machine's copy carries it.
/// </summary>
public Guid NetId { get; set; }
/// <summary>Find a drop by its shared id, on any machine.</summary>
public static Powerup ById( Guid id )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() || id == default ) return null;
foreach ( var p in scene.GetAllComponents<Powerup>() )
if ( p.IsValid() && p.NetId == id ) return p;
return null;
}
/// <summary>
/// The host collected this one. Clients only.
///
/// ⚠️ IT RUNS THE REAL `Collect` so the banner, the cues, the announcer and the timed
/// registry all come from the one method that owns them — a hand-rolled subset here is how
/// a client ends up with the sound and not the timer, or the timer and not the banner.
///
/// ⚠️ WITH THIS MACHINE'S OWN PLAYER as the collector. The instant effects — max ammo, the
/// points bonus — are applied to whoever is passed in, and on a client that must be the
/// person sitting at it. Max Ammo refills EVERYONE in nZombies, so each machine applying it
/// to its own player is not an approximation; it is the rule.
/// </summary>
public static void CollectRemote( Guid id, PowerupKind kind, int pointsOverride, Guid collector )
{
var p = ById( id );
if ( p.IsValid() )
{
p.StopHum();
p._taken = true;
p.GameObject.Destroy();
}
// ── the TEAM half: everybody, every time ──────────────────────────────────────────
//
// ⚠️ THE BANNER, THE CUES AND THE TIMER ARE THE POWERUP HAPPENING, and a powerup happens
// to the whole team. Insta-Kill and Double Points are read live out of `ActivePowerups`
// by whatever they affect, so every machine has to start its own clock.
PowerupBannerState.Show( PowerupBannerState.NameFor( kind ) );
ActivePowerups.Activate( kind );
// ⛔ THE CUES WERE NEVER PLAYED HERE, AND `Collect`'S OWN COMMENT SAYS THEY SHOULD BE:
// *"EVERY MACHINE GETS THE BANNER, THE CUES AND THE TIMER — a powerup in this game is a
// TEAM event."* Two of those three arrived. A client saw INSTA-KILL flash across the
// screen in silence, which is the half of the announcement that carries least.
// User: *"the anouncer for the powerups."*
//
// ⚠️ NOT THROUGH `WorldSound`. This method already runs on every machine — it is the
// broadcast — so relaying again would be a second message for a sound already arriving.
//
// ⚠️ AT THE POWERUP IF IT IS STILL THERE, AT THE LISTENER IF IT IS NOT. The object is
// destroyed a few lines above, and a message that arrives after it was already cleaned up
// locally must still be heard rather than played at the world origin.
var at = p.IsValid() ? p.WorldPosition : (Vector3?)null;
PlayCues( kind, at );
// ── the INSTANT half: the collector alone ─────────────────────────────────────────
//
// ⛔ `PowerupEffects.Apply` PAYS AND REFILLS THE PLAYER IT IS GIVEN, and it must be given
// one player once. Running it on every machine for that machine's own player paid a
// client's own drop twice — the host's `AddPoints` relays to the owner, and then the
// owner's copy applied it again. Running it NOWHERE, for a natural drop whose points the
// host had already relayed, is how the other half of the same bug looked from the client:
// *"if a client picks up a naturally spawning bonus points they earn nothing."*
//
// ⚠️ MAX AMMO IS THE EXCEPTION AND IT IS DELIBERATE. It refills EVERYONE in nZombies, so
// it is a team effect wearing an instant effect's clothes — applied here on every machine,
// to that machine's own player.
if ( PowerupEffects.IsTeamEffect( kind ) )
{
PowerupEffects.Apply( kind, NZPlayer.Local, 0 );
return;
}
if ( Connection.Local is not null && Connection.Local.Id == collector )
PowerupEffects.Apply( kind, NZPlayer.Local, pointsOverride );
}
/// <summary>
/// The host says a powerup dropped. Clients only — build the same scenery, and nothing else.
///
/// ⚠️ IT GOES THROUGH THE SAME `Spawn` the host used, so a client's copy hovers, tumbles and
/// glows identically rather than being a second, simpler thing that has to be kept in step.
/// </summary>
public static void SpawnRemote( Guid id, Vector3 pos, PowerupKind kind, int pointsOverride = -1 )
{
if ( ById( id ).IsValid() ) return;
var p = Spawn( pos, kind, pointsOverride );
if ( p.IsValid() ) p.NetId = id;
}
/// <param name="pointsOverride">
/// What a Bonus Points drop is worth, or -1 for none: a natural drop, which rolls for the team.
///
/// ⛔ -1, NOT 0, AND 0 WAS THE BUG. This defaulted to 0 while `PointsOverride` and
/// `PowerupEffects.BonusPoints` both read "0 or more" as a player's own drop — so every natural Bonus
/// Points paid its collector exactly 0 and nobody else anything. User: *"bonus points is not giving
/// points to any player."* (2026-09-27)
///
/// ⛔ AN ARGUMENT RATHER THAN A FIELD SET AFTERWARDS. `Spawn` announces the drop to every
/// machine, and it does that before returning — so a caller assigning `PointsOverride` on the
/// way out is assigning it AFTER the message has gone, and every other machine's copy is worth
/// the default instead of what the player paid.
/// </param>
public static Powerup Spawn( Vector3 pos, PowerupKind kind = PowerupKind.MaxAmmo, int pointsOverride = -1 )
{
var path = ModelFor( kind );
var model = Model.Load( path );
if ( model is null || model.IsError )
{
Log.Warning( $"[nz] powerup model '{path}' missing — not spawning" );
return null;
}
var go = new GameObject( true, $"powerup_{kind}" );
go.NetworkMode = NetworkMode.Never;
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
var p = go.Components.Create<Powerup>();
p.Kind = kind;
p.PointsOverride = pointsOverride;
// ⚠️ THE HOST GIVES IT AN IDENTITY AND TELLS EVERYONE. Clients reach this same method
// through `SpawnRemote`, which sets the id itself and must not announce it again.
//
// ⚠️ AFTER `PointsOverride`, so what the message carries is what this drop is actually
// worth. A caller setting it on the returned object would be a frame and a network message
// too late.
if ( Networking.IsActive && NZGame.IsHost )
{
p.NetId = Guid.NewGuid();
NZNet.PowerupDropped( p.NetId, pos, (int)kind, pointsOverride );
}
// ⚠️ AFTER the component exists, so it uses the component's own `Hover`
// rather than a literal that would silently disagree with it.
go.WorldPosition = pos + Vector3.Up * p.Hover;
// ⛔ THE GLOW IS A CHILD, NOT A COMPONENT ON THE POWERUP ITSELF. The powerup
// TUMBLES — a billboard on the same object inherits that rotation, and a
// billboard fighting a spin flickers as the two resolve against each other.
// A child that never rotates just sits there glowing.
var glowGO = new GameObject( true, "glow" );
glowGO.SetParent( go, false );
glowGO.LocalPosition = Vector3.Zero;
var glow = glowGO.Components.Create<SpriteRenderer>();
var sprite = ResourceLibrary.Get<Sprite>( GlowSprite );
if ( sprite is not null ) glow.Sprite = sprite;
glow.Size = new Vector2( p.GlowSize, p.GlowSize );
glow.Color = p.GlowColor;
glow.Additive = true;
// ⚠️ Unlit and shadowless — it is emissive by definition, and a glow that
// takes the room's lighting goes dim in exactly the dark corners where a
// powerup most needs to be spotted.
glow.Lighting = false;
glow.Shadows = false;
// ⚠️ Softens where the sprite meets the floor, so it does not cut a hard
// circle into the ground.
glow.DepthFeather = 8f;
p._glow = glow;
Log.Info( $"[nz] powerup {kind} dropped at {go.WorldPosition} — "
+ $"{p.Lifetime:0}s, armed in {p.ArmDelay:0.#}s" );
return p;
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>
/// Drop one in front of you: `nz_powerup [kind] [distance]`.
///
/// ⚠️ THE DISTANCE EXISTS TO TEST PICKUP. The default 80u puts it outside the 48u
/// radius so you can see it before you take it — which also means the command
/// cannot verify collection on its own. `nz_powerup maxammo 0` drops it on your
/// feet and it is taken the moment the arm delay elapses.
/// </summary>
[ConCmd( "nz_powerup" )]
public static void Cmd( string kind = "maxammo", float distance = 80f )
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
var controller = player.Components.Get<PlayerController>();
var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
var fwd = (controller?.EyeAngles.ToRotation() ?? player.WorldRotation).Forward;
// Dropped a little ahead and traced down, so it lands ON the floor rather
// than hanging wherever the eye happened to be.
var from = eye + fwd * distance;
var tr = Game.ActiveScene.Trace.Ray( from, from + Vector3.Down * 200f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
var at = tr.Hit ? tr.HitPosition : from;
if ( !TryParseKind( kind, out var k ) )
{
// ⛔ WARN, DO NOT FALL BACK SILENTLY. This used to default to MaxAmmo on
// any unrecognised name, so a typo spawned the wrong powerup and read as
// "the command ignores its argument" — the failure looked like the
// feature was broken rather than the input.
Log.Warning( $"[nz] no powerup called '{kind}'. Try: {KindNames()}" );
return;
}
Spawn( at, k );
}
/// <summary>
/// Resolve a name to a kind, accepting the short forms people actually type.
///
/// ⚠️ The enum names are the long forms (`DoublePoints`), but everyone says "dp"
/// and "2x" — the original's own data calls it `dp` and its model is `2x.mdl`. A
/// command that only accepts the C# spelling makes you look the enum up.
/// </summary>
public static bool TryParseKind( string name, out PowerupKind kind )
{
kind = PowerupKind.MaxAmmo;
if ( string.IsNullOrWhiteSpace( name ) ) return false;
switch ( name.Trim().ToLowerInvariant() )
{
case "maxammo": case "max": case "ammo":
kind = PowerupKind.MaxAmmo; return true;
case "instakill": case "insta": case "ik":
kind = PowerupKind.InstaKill; return true;
case "doublepoints": case "dp": case "2x": case "double":
kind = PowerupKind.DoublePoints; return true;
case "bonuspoints": case "bonus": case "points":
kind = PowerupKind.BonusPoints; return true;
case "carpenter": case "carp":
kind = PowerupKind.Carpenter; return true;
case "nuke": case "bomb":
kind = PowerupKind.Nuke; return true;
case "firesale": case "sale": case "fire":
kind = PowerupKind.FireSale; return true;
}
// Anything else still resolves if it matches an enum name exactly-ish, so
// the unported kinds (FireSale, DeathMachine) remain spawnable for testing.
return System.Enum.TryParse( name, true, out kind );
}
/// <summary>The names `nz_powerup` accepts, for the error message.</summary>
public static string KindNames()
=> "maxammo, instakill, doublepoints, bonuspoints, carpenter, nuke, firesale";
/// <summary>
/// One of each, in a row: `nz_powerup_all`.
///
/// ⚠️ SPREAD ALONG A LINE rather than stacked on one spot — the whole point is
/// comparing the models and their glows side by side, and six overlapping pickups
/// is one pickup you cannot see.
///
/// ⚠️ Arm delay aside, they are collectable: walk the line and every announcer
/// fires in turn, which is the fastest way to check the audio set.
/// </summary>
[ConCmd( "nz_powerup_all" )]
public static void All()
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
var controller = player.Components.Get<PlayerController>();
var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;
var kinds = new[]
{
PowerupKind.MaxAmmo, PowerupKind.InstaKill, PowerupKind.DoublePoints,
PowerupKind.BonusPoints, PowerupKind.Carpenter, PowerupKind.Nuke,
PowerupKind.FireSale,
};
// Centred on the player's facing, 90u apart.
for ( int i = 0; i < kinds.Length; i++ )
{
var offset = (i - (kinds.Length - 1) / 2f) * 90f;
var from = eye + rot.Forward * 160f + rot.Right * offset;
var tr = Game.ActiveScene.Trace.Ray( from, from + Vector3.Down * 300f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
Spawn( tr.Hit ? tr.HitPosition : from, kinds[i] );
}
Log.Info( $"[nz] spawned {kinds.Length} powerups in a row" );
}
/// <summary>What is on the floor: `nz_powerups`.</summary>
[ConCmd( "nz_powerups" )]
public static void List()
{
var all = Game.ActiveScene?.GetAllComponents<Powerup>().ToList();
if ( all is null || all.Count == 0 )
{
Log.Info( "[nz] no powerups on the floor" );
return;
}
foreach ( var p in all )
Log.Info( $"[nz] {p.Kind,-14} {p.Remaining:0.0}s left"
+ (p.Blinking ? " BLINKING" : "")
+ $" at {p.WorldPosition}" );
}
}
/// <summary>
/// Every powerup the original has. ⚠️ NAMED NOW, WIRED LATER — the shell does not
/// care which one it is, and having the list present keeps `ModelFor` and the drop
/// tables honest about what is still missing.
/// </summary>
public enum PowerupKind
{
MaxAmmo,
InstaKill,
DoublePoints,
BonusPoints,
Nuke,
Carpenter,
FireSale,
// ⚠️ Named but NOT imported — no model, no cue, no effect. Resolves to the
// fallback can and a silent pickup. Listed so the drop table and `ModelFor` stay
// honest about what is still missing.
DeathMachine,
/// <summary>
/// Widow's Wine M4 — refills one grenade.
///
/// ⛔ NOT IN THE RANDOM DROP TABLE, deliberately. `PowerupDrops` rolls the seven classic
/// power-ups on any kill; this one is rolled separately by `WidowAugments` and only for a
/// player holding the augment. Adding it to that table would hand it to everybody.
/// </summary>
Spider,
}