Component for a Pack-a-Punch machine. Manages placement, visuals, sound cues, player interaction (inserting weapon, paying, upgrade timing, collecting or expiring), and per-frame animation of the displayed weapon.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// PACK-A-PUNCH — feed it your gun, get it back hitting harder.
///
/// Ported from the original's `pap` machine (perk_machine + pap_weapon_fly).
///
/// ⛔ IT IS REGISTERED AS A PERK IN THE ORIGINAL AND IT IS NOT ONE. `sh_perks.lua`
/// declares it with `specialmachine = true` and `nobuy = true`, which skips the
/// entire own-a-perk path — no bottle, no icon slot, nothing persists on the
/// player. It reuses the perk machine only because that entity already knew how to
/// stand somewhere, gate on power and take money. So this is its own component
/// rather than the first perk: modelling it as a perk would mean building the perk
/// system around the one thing that is not one.
///
/// ⚠️ The machine barely animates — the model carries a single `idle` sequence.
/// The gun going in and coming out is a SEPARATE entity there (`pap_weapon_fly`),
/// and here it is the same floating-offer object the mystery box already uses.
/// </summary>
public sealed class PackAPunch : Component
{
/// <summary>Every live machine, for the use trace and the prompt.</summary>
public static readonly List<PackAPunch> All = new();
// ⛔ `Costs` REMOVED. It was a `[Property] List<int>` on this component and is now
// `MapConfig.Pap.Costs`, edited from the settings panel. Two places holding the price ladder
// meant the panel could disagree with the machine standing in front of you, and nothing
// serialized this one anyway (checked: no "Costs" in Assets) because machines are built at
// runtime by PackAPunchManager. §3: one author per value.
/// <summary>How close you must stand. Matches the box and the barricade.</summary>
[Property] public float Reach { get; set; } = 90f;
/// <summary>The machine. BO2 vending PaP, same pack and author as the crate.</summary>
[Property] public string MachineModel { get; set; } = "models/nz/pap/vending_pap.vmdl";
/// <summary>
/// How long the machine works before handing the gun back.
///
/// The original's `PerkTime = CurTime() + 3.5` (perk_machine:1166).
/// </summary>
[Property] public float WorkTime { get; set; } = 3.5f;
/// <summary>How long the finished gun waits to be collected.</summary>
[Property] public float ReadyTime { get; set; } = 20f;
/// <summary>
/// Where the weapon floats, above the machine's base.
///
/// ⚠️ 42 IS THE THROAT OPENING, found with `nz_pap_gun` against the real model
/// rather than guessed — 52 was a first estimate made before there was a machine
/// to look at, and hung the gun up near the top edge. 37 was also tried and is
/// too low: it rests the weapon on the bottom lip, which reads as a gun left
/// lying on the machine rather than one being presented by it.
/// </summary>
[Property] public float GunHeight { get; set; } = 42f;
/// <summary>
/// How far IN FRONT of the machine the weapon sits when presented.
///
/// ⚠️ The machine's local +x points at whoever placed it (`nz_pap` sets the
/// spot's yaw from the player), so forward is out towards the player and this is
/// simply a positive offset — no sign juggling per placement.
///
/// ⚠️ 28.8, tuned by eye in two passes: 40 hung the weapon clear of the machine
/// and read as a gun floating NEAR a Pack-a-Punch, 14.4 overcorrected and tucked
/// it almost against the throat. This sits between them — offered by the machine,
/// with enough travel that the sink-back still reads as reclaiming it.
/// </summary>
[Property] public float GunOutDistance { get; set; } = 28.8f;
/// <summary>How deep into the roller throat it travels while being worked on.</summary>
[Property] public float GunInDepth { get; set; } = 2f;
/// <summary>How long the weapon takes to travel in, and to come back out.</summary>
[Property] public float TravelTime { get; set; } = 0.7f;
/// <summary>
/// `TravelTime` after Timeslip m1 Overclock.
///
/// ⛔ ONE PROPERTY, FOUR READERS. `TravelTime` is read by the two `_next` deadlines AND by
/// the two animation lerps that slide the gun in and out. Scaling only the deadlines would
/// run the animation at normal speed against a deadline 20x shorter — the gun would jump
/// rather than travel, which looks like a broken machine rather than a fast one.
///
/// ⚠ Resolved from `_owner`, the player who started the cycle, exactly as
/// `MachineCycleMultiplier` is. Null-safe: no owner resolves to the unscaled time.
/// </summary>
float EffectiveTravel
=> MathF.Max( 0.01f, TravelTime * TimeAugments.PapCycleScale( _owner ) );
/// <summary>How the presented weapon is angled. Same broadside idea as the box.</summary>
[Property] public Angles GunAngles { get; set; } = new( 0f, 90f, 0f );
protected override void OnEnabled()
{
if ( !All.Contains( this ) ) All.Add( this );
BuildVisual();
// ⛔ SEEDED HERE, BECAUSE A DEFAULT `TimeUntil` HAS ALREADY ELAPSED. Left unset,
// `_nextJingle` reads as due on the machine's very first tick — so every Pack-a-Punch on
// the map jingled simultaneously the moment the power came on, and again in unison
// whenever their random gaps happened to line up. That is half of "the jingle plays
// multiple times at once", and it was not the sound system's fault.
_nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );
}
protected override void OnDisabled()
{
All.Remove( this );
ClearGun();
StopLoop();
}
// ── ambience ─────────────────────────────────────────────────────────────
SoundHandle _loop;
TimeUntil _nextJingle;
// ══ ONE PACK-A-PUNCH CUE AT A TIME, ACROSS EVERY MACHINE ══════════════════════
//
// ⛔ STATIC ON PURPOSE, and it is the one thing on this component that should be. Per-machine
// state cannot stop TWO machines singing over each other, which is exactly what was reported.
// A map with three Pack-a-Punches had three independent jingle timers and nothing between
// them.
//
// ⚠ A HANDLE, NOT A GUESSED DURATION. `NZSound.Play` returns a `SoundHandle`, so the engine
// tells us when the cue is done — no table of sample lengths to keep in step with the assets.
// `_loop` above is managed the same way and its note says why: checking IsValid is cheaper
// than tracking a duration, and it self-heals if the handle is lost.
static SoundHandle _cue;
/// <summary>
/// Upper bound on how long a cue can block the jingle.
///
/// ⛔ A FAILSAFE FOR THE STATIC ABOVE. A static SoundHandle survives a hotload (§1), so a
/// leaked or stale handle that keeps reading valid would silence every jingle on the map
/// FOREVER — and silence is the one bug that never reports itself. A `TimeUntil` always
/// elapses, so the worst case is one jingle skipped rather than all of them.
/// </summary>
static TimeUntil _cueCap;
/// <summary>Seconds after which a cue stops counting as playing. Generous on purpose.</summary>
public static float CueCapSeconds { get; set; } = 15f;
/// <summary>Is any Pack-a-Punch cue currently sounding, on any machine.</summary>
static bool CuePlaying => _cue.IsValid() && !_cueCap;
/// <summary>
/// Play a Pack-a-Punch cue and record it, so the idle jingle knows to stay quiet.
///
/// ⚠ EVERY PaP CUE GOES THROUGH HERE. A cue played with a bare `NZSound.Play` is invisible
/// to `CuePlaying`, and the jingle would talk over it — which is the other half of what was
/// reported.
/// </summary>
static void PlayCue( string cue, Vector3 at )
{
_cue = NZSound.Play( cue, at );
_cueCap = CueCapSeconds;
}
/// <summary>
/// Seconds between jingles, picked fresh each time within this range.
///
/// ⚠️ LONG AND RANDOM, as the original does it (`NextJingle = CurTime() +
/// math.random(0,600)`). The jingle is meant to be something you occasionally
/// notice from across the map, not a soundtrack — on a short fixed timer it
/// stops being an event and starts being wallpaper.
/// </summary>
[Property] public Vector2 JingleGap { get; set; } = new( 90f, 300f );
void StopLoop()
{
if ( _loop.IsValid() ) _loop.Stop();
_loop = default;
}
/// <summary>
/// Keep the hum running while there is power, and drop a jingle now and then.
///
/// ⛔ GATED ON POWER, like every machine in the original. A Pack-a-Punch humming
/// away before the power is on is a promise the map cannot keep — and it is the
/// sound, not the model, that makes a player walk over to try.
///
/// ⚠️ The loop is RESTARTED rather than left running, because a 1.23s sample
/// ends. Checking IsValid each tick and replaying is cheaper than tracking its
/// duration, and it self-heals if the handle is ever lost.
/// </summary>
void TickAmbience()
{
if ( !Power.IsOn )
{
StopLoop();
return;
}
// ⛔ PlayAmbient, NOT Play — it drops cues emitted beyond their own falloff.
// The sample is 1.23s, so "keep it running" means restarting it about once a
// second, forever, per machine. Unculled that is a stream of silent sounds
// for anyone not standing there, and with the audio trace on it buries every
// other cue in the console — which is how it was noticed.
if ( !_loop.IsValid() )
_loop = NZSound.PlayAmbient( NZSound.PapLoop, WorldPosition );
// ⚠ THE TIMER IS ONLY RESET WHEN THE JINGLE ACTUALLY PLAYS. Resetting it on a blocked
// attempt would push the next one a further 90-300s away for no reason the player could
// see; leaving it due means the jingle happens as soon as the machine is quiet again.
if ( _nextJingle && !CuePlaying )
{
_nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );
PlayCue( NZSound.PapJingle, WorldPosition );
}
}
GameObject _visual;
GameObject _gunGO;
ModelRenderer _gunRenderer;
void BuildVisual()
{
_visual?.Destroy();
var go = Scene.CreateObject();
go.Name = "machine";
go.SetParent( GameObject );
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⚠️ Local AFTER parenting. SetParent preserves WORLD position, so a child
// made by Scene.CreateObject stays at the origin and merely acquires a
// compensating offset — the bug that made the mystery box invisible for two
// rounds of diagnosis.
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
var model = Model.Load( MachineModel );
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
// ⛔ IT HAD NO COLLIDER AND YOU WALKED STRAIGHT THROUGH IT. Found only
// because someone asked for a sound when they ran into it — a machine you
// can stand inside is worse than one that makes no noise.
//
// ⛔ A BOX, NOT A ModelCollider. Our vmdl is a mesh-only OBJ import with no
// PhysicsShapeList, so a ModelCollider has no hulls to build from. A box is
// also the better answer on its own merits: a vending machine IS a box, and
// a mesh collider off this model would snag the player on the sign bracket
// and the trestle legs for no gain.
//
// ⚠️ Sized from the model's own bounds, so it stays correct if the machine
// model is ever swapped for one of the other PaP variants.
if ( model is not null )
{
var box = go.Components.Create<BoxCollider>();
box.Scale = model.Bounds.Size;
box.Center = model.Bounds.Center;
box.Static = true;
}
_visual = go;
}
// ── walking into it ──────────────────────────────────────────────────────
/// <summary>How close counts as bumping it. Machine half-width plus a body.</summary>
[Property] public float BumpReach { get; set; } = 62f;
/// <summary>
/// How fast you must be closing to make it clang.
///
/// ⚠️ SPEED TOWARD THE MACHINE, not raw speed. Standing at the machine to use it
/// means being inside BumpReach for as long as you are there — without the
/// approach test it would clang while you stood still deciding, and again on
/// every strafe.
/// </summary>
[Property] public float BumpSpeed { get; set; } = 60f;
/// <summary>Quietest gap between two clangs, so a wall-hug does not rattle.</summary>
[Property] public float BumpCooldown { get; set; } = 0.7f;
TimeUntil _bumpReady;
void TickBump()
{
if ( !_bumpReady ) return;
foreach ( var player in Scene.GetAllComponents<NZPlayer>() )
{
if ( !player.IsValid() ) continue;
var toMachine = WorldPosition.WithZ( player.WorldPosition.z ) - player.WorldPosition;
if ( toMachine.Length > BumpReach ) continue;
// ⚠️ Velocity PROJECTED onto the approach direction. A player running
// past the machine at speed is not running into it, and would otherwise
// set it off every time they rounded the corner.
var controller = player.Components.Get<PlayerController>();
var vel = controller?.Velocity ?? Vector3.Zero;
if ( vel.Dot( toMachine.Normal ) < BumpSpeed ) continue;
_bumpReady = BumpCooldown;
NZSound.Play( NZSound.MachineBump, WorldPosition );
return;
}
}
// ── state ────────────────────────────────────────────────────────────────
/// <summary>
/// What the machine is doing. Idle is the only state that takes a gun.
///
/// ⚠️ Inserting and Ejecting are TRAVEL states, and they exist so the weapon is
/// seen going in and coming out rather than teleporting. The original splits
/// them the same way — `take_gun` and `eject_gun` are separate sequences either
/// side of the work, not one animation.
/// </summary>
public enum PapState { Idle, Inserting, Working, Ejecting, Ready }
[Property, ReadOnly] public PapState State { get; private set; } = PapState.Idle;
TimeUntil _next;
NZPlayer _owner;
string _weapon = "";
int _newLevel;
/// <summary>Is there a finished gun sitting here?</summary>
public bool HasFinishedGun => State == PapState.Ready;
/// <summary>Mid-cycle — no new gun can go in.</summary>
public bool IsBusy => State != PapState.Idle;
/// <summary>Whose gun is in there. Nobody else may take it.</summary>
public NZPlayer Owner => _owner;
/// <summary>
/// How far out in front the gun's MESH currently is, or null.
///
/// ⚠️ Measured along the machine's own FORWARD, not world x — the machine is
/// placed facing whoever put it down, so a world-axis reading changes meaning
/// with every placement. Same lesson the mystery box's watch learned about
/// measuring a child in its parent's frame.
/// </summary>
public float? GunOut
{
get
{
if ( !_gunGO.IsValid() || !_gunRenderer.IsValid() ) return null;
var model = _gunRenderer.Model;
if ( model is null ) return null;
var centre = _gunGO.WorldPosition + _gunGO.WorldRotation * model.Bounds.Center;
return (centre - WorldPosition).Dot( WorldRotation.Forward );
}
}
/// <summary>What the next pack would cost this player, or 0 if they cannot.</summary>
public int PriceFor( NZPlayer player )
{
if ( !player.IsValid() || string.IsNullOrWhiteSpace( player.StartingWeapon ) ) return 0;
// ⚠️ `CostToReach` RETURNS 0 BOTH FOR "fully packed" AND for a malformed array, so callers
// must check `IsMaxed` first — 0 reads as free rather than as refused. `Insert` does.
return ActiveConfig.Pap?.CostToReach( player.PapLevelFor( player.StartingWeapon ), NZPlayer.PapMaxLevel ) ?? 0;
}
/// <summary>Is this weapon already fully packed?</summary>
public static bool IsMaxed( NZPlayer player )
=> player.IsValid()
&& player.PapLevelFor( player.StartingWeapon ) >= NZPlayer.PapMaxLevel;
/// <summary>The highest tier the CURRENT round allows to be bought.</summary>
public static int TierCapForNow()
{
var pap = ActiveConfig.Pap;
if ( pap is null ) return NZPlayer.PapMaxLevel;
// ⛔ CREATIVE IGNORES THE ROUND GATE ENTIRELY. The gate is a PACING rule — it exists so a
// run has something left to unlock at round 45 — and creative has no run and no rounds to
// wait through. Left gated, the round reads 0, `MaxTierForRound` clamps to MK1, and the
// mode built for testing the other four tiers cannot reach them.
//
// ⚠️ THE SAME REASON WALLBUYS ARE FREE IN CREATIVE (`WallBuy.cs`: `var free =
// NZGame.IsCreative`). An economy rule and a pacing rule are both rules about a RUN, and
// creative is not one.
if ( NZGame.IsCreative ) return NZPlayer.PapMaxLevel;
var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;
// ⚠️ ON THE LADDER AS IT STANDS — six with basalt's Easter egg complete. The config's own Tiers would stop the gate at
// MK5, and MK6 could never be bought.
return Math.Min( NZPlayer.PapMaxLevel, pap.MaxTierForRound( round, NZPlayer.PapMaxLevel ) );
}
/// <summary>
/// Is the next tier locked behind a round this player has not reached?
///
/// ⛔ SEPARATE FROM `IsMaxed`, AND THE DIFFERENCE IS THE WHOLE FEATURE. "Fully upgraded"
/// and "not yet" are different answers and must read differently at the machine —
/// collapsing them into one refusal would tell a round-12 player their MK1 rifle is
/// finished, which is both wrong and unrecoverable advice.
/// </summary>
public static bool IsRoundLocked( NZPlayer player )
{
if ( !player.IsValid() || IsMaxed( player ) || NZGame.IsCreative ) return false;
// ⛔ THE NEXT TIER'S OWN GATE, NOT THE CAP FOR NOW. MK6 has no round — basalt's Easter egg is its only gate, and
// `IsMaxed` has said whether that is open — so a gun that came by an MK5 early (the Wildcard, the trade table) packs to
// MK6 at once, where the cap for now, counted up the ladder, stops at the first locked round below it.
var pap = ActiveConfig.Pap;
if ( pap is null ) return false;
var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;
return !pap.TierOpenAt( player.PapLevelFor( player.StartingWeapon ) + 1, round );
}
/// <summary>
/// Put the held weapon in. Returns what happened, for the log and the prompt.
///
/// ⚠️ THE WEAPON IS TAKEN OFF THE PLAYER IMMEDIATELY. That is the cost of using
/// the machine and it is the original's behaviour too (`StripWeapon(class)`) —
/// standing at a PaP with a horde inbound should be a decision, not a freebie.
/// </summary>
public string Insert( NZPlayer player )
{
if ( !player.IsValid() ) return "no player";
if ( State != PapState.Idle ) return "";
if ( !Power.IsOn ) return "the machine has no power";
var prefab = player.StartingWeapon;
if ( string.IsNullOrWhiteSpace( prefab ) ) return "you have nothing to pack";
if ( IsMaxed( player ) )
return $"already fully upgraded (MK{NZPlayer.PapMaxLevel})";
// ⛔ CHECKED BEFORE THE PRICE, so a locked tier never takes points. `TrySpend` fuses
// the check with the deduction, so any refusal reached after it has already charged.
//
// ⚠️ IT NAMES THE ROUND. "Not available yet" tells the player nothing they can act on;
// the round number turns a refusal into a goal.
if ( IsRoundLocked( player ) )
{
var next = player.PapLevelFor( player.StartingWeapon ) + 1;
var at = ActiveConfig.Pap?.UnlockRoundFor( next - 1 ) ?? 0;
return $"MK{next} unlocks at round {at}";
}
int price = PriceFor( player );
if ( price <= 0 ) return "nothing to upgrade";
// ⚠️ TrySpend fuses the check with the deduction, as everywhere else —
// asking CanAfford separately is how you get free packs.
if ( !player.TrySpend( price ) )
return $"not enough points — MK{player.PapLevelFor( prefab ) + 1} costs {price}";
// ⚠ TIMESLIP m2 TIME OUT — AFTER THE SPEND SUCCEEDED, never before. A refused purchase
// must not buy 15 seconds of invisibility; that is the same reasoning the box's own
// `Uses++` comment gives two lines down.
TimeAugments.OnMachineUsed( player, "pack-a-punch" );
_owner = player;
_weapon = prefab;
_newLevel = player.PapLevelFor( prefab ) + 1;
ShowGun( prefab );
// ⛔ STRIPPED, NOT HIDDEN. Leaving the weapon equipped while its model sits
// in the machine lets you keep shooting the gun you just handed over.
StripWeapon( player );
// ⚠️ WHEN THE GUN GOES IN, NOT WHEN IT COMES OUT. `Finish()` is the machine returning to Idle
// — several seconds later, after the player has already walked off with the weapon. The line
// is a reaction to handing it over.
CharacterVoice.Say( "upgrading", player );
State = PapState.Inserting;
_next = EffectiveTravel;
_sinceStage = 0f;
// ⚠️ Sting AND work loop together — the sting is the machine acknowledging
// the keypress, the work sound is what it does afterwards. The original
// plays them as separate cues for the same reason.
// ⚠ THE WORK LOOP IS THE ONE RECORDED, and the sting rides along unrecorded. They start
// together and there is one cue slot, so the pair is bounded by whichever is recorded —
// the work sound, because it is the one that lasts for the cycle. Recording the sting
// instead would let the jingle cut in over the tail of the work loop.
NZSound.Play( NZSound.PapSting, WorldPosition );
PlayCue( NZSound.PapWork, WorldPosition );
return $"packing {WeaponName( prefab )} -> MK{_newLevel} for {price}";
}
/// <summary>
/// Apply the pack level and put the gun in the player's hands. Returns its new name.
///
/// ⛔ SHARED BY `Collect` AND TIMESLIP m1's INSTANT PATH, and that sharing is the point.
/// m1 skips the machine entirely, so without one implementation there would be two answers
/// to "what does packing a gun do" — and the one that drifted would be the augment's, because
/// the machine's is the one that gets played every round. §3 with a purchase attached.
///
/// ⚠ `GiveWeapon`, not StartingWeapon + re-equip. On the machine path the weapon was
/// stripped on insert so the slot is free; on m1's path `GiveOrReplace` swaps the same prefab
/// in place. Either way the OTHER weapon the player kept is untouched.
///
/// ⚠ THE LEVEL IS READ BACK FROM `AddPapLevel` rather than computed here. That method owns
/// the ceiling, so a gun already at MK3 reports MK3 instead of an invented MK4.
/// </summary>
string Upgrade( NZPlayer player, string prefab )
{
int level = player.AddPapLevel( prefab );
player.GiveWeapon( prefab );
return $"{WeaponName( prefab )} MK{level}";
}
/// <summary>
/// Collect the finished weapon.
///
/// ⛔ ONLY THE OWNER. `pap_weapon_fly:Use` checks `ply == self:GetPaPOwner()`,
/// and it matters: without it a teammate walks off with the gun you paid
/// 30,000 points to upgrade.
/// </summary>
public string Collect( NZPlayer player )
{
if ( !player.IsValid() || State != PapState.Ready ) return "";
if ( player != _owner ) return "that is not your weapon";
var name = Upgrade( player, _weapon );
Finish();
NZSound.Play( NZSound.Purchase, WorldPosition );
return $"took the {name}";
}
/// <summary>
/// Nobody collected it. The weapon is returned UNUPGRADED and the points stay
/// spent.
///
/// ⚠️ RETURNED, NOT DESTROYED — the original leaves you a backup weapon and
/// removes the fly entity, which amounts to the same thing: you are not left
/// unarmed by walking away. Losing the money is the punishment; losing the gun
/// as well would be a trap rather than a cost.
/// </summary>
void Expire()
{
if ( _owner.IsValid() && !string.IsNullOrEmpty( _weapon ) )
{
_owner.GiveWeapon( _weapon );
Log.Info( $"[nz] nobody collected the {WeaponName( _weapon )} — "
+ "returned unupgraded" );
}
Finish();
}
/// <summary>
/// Back to idle, holding nothing.
///
/// ⛔ NOT CALLED `Reset` — that HIDES `Component.Reset()`, and this is the THIRD
/// time in this project: `DamageOverlay.Enabled` shadowed `Component.Enabled`,
/// `Barricade.Reset` shadowed this same method, and here it is again. The
/// compiler only warns, so the shadowed version compiles and runs and the
/// engine's own call goes somewhere unexpected. Check the base class before
/// naming any public member on a Component.
/// </summary>
void Finish()
{
ClearGun();
State = PapState.Idle;
_owner = null;
_weapon = "";
_newLevel = 0;
}
protected override void OnUpdate()
{
TickGun();
TickAmbience();
TickBump();
if ( State == PapState.Idle ) return;
if ( !_next ) return;
switch ( State )
{
case PapState.Inserting:
State = PapState.Working;
// Timeslip Tonic halves the upgrade. Resolved from _owner, set by
// Insert() before this state is ever reached, so it is the player who
// put the gun in — not whoever is standing at the machine now.
//
// ⚠️ ReadyTime is deliberately NOT scaled: that is the window to
// COLLECT the weapon, and shortening it would make the perk a
// downgrade. See PerkEffects.MachineCycleMultiplier.
// ⚠ m1 OVERCLOCK SCALES THE SAME PRODUCT as the base perk. `MathF.Max` keeps a
// zero work time from stalling the state machine on a deadline that has already
// elapsed.
_next = MathF.Max( 0.01f,
WorkTime
* PerkEffects.MachineCycleMultiplier( _owner )
* TimeAugments.PapCycleScale( _owner ) );
_sinceStage = 0f;
break;
case PapState.Working:
State = PapState.Ejecting;
// ⛔ `EffectiveTravel`, NOT `TravelTime` — THE ONE READER THAT WAS MISSED. Its own
// header says "one property, four readers"; this was the fourth and it took the raw
// value. So under Timeslip m1 Overclock the eject DEADLINE stayed 0.7s while the
// eject ANIMATION (`_sinceStage / EffectiveTravel`, twice below) ran on the
// shortened one — the weapon finished sliding out and then sat there, or the state
// advanced mid-slide, depending on which way the augment scaled.
_next = EffectiveTravel;
_sinceStage = 0f;
// ⛔ THE CAMO GOES ON AS IT STARTS COMING OUT, not on arrival. This is the frame
// the weapon stops being the one you handed over and becomes the upgraded one, so
// it must emerge ALREADY packed — painting it at Ready instead makes the gun
// travel out plain and then flip mid-air, which reads as a bug rather than a
// reveal. It was previously never painted at all: the machine's display gun is a
// plain ModelRenderer that no code touched, so the one moment the player gets a
// clear, close look at their upgrade showed them an un-upgraded gun.
PaintGun();
// ⚠️ The ready cue fires as it STARTS coming out, not when it
// arrives. The sound is what makes you look up, and by the time you
// do the weapon should already be moving toward you.
NZSound.Play( NZSound.PapReady, WorldPosition );
break;
case PapState.Ejecting:
State = PapState.Ready;
_next = ReadyTime;
// ⚠️ Reset, because Ready is now a TRAVEL stage too — the weapon
// sinks back in across the whole grab window and needs its own clock.
_sinceStage = 0f;
break;
case PapState.Ready:
Expire();
break;
}
}
// ── the weapon in the machine ────────────────────────────────────────────
void ShowGun( string prefab )
{
ClearGun();
var model = ModelForPrefab( prefab );
var go = Scene.CreateObject();
go.Name = "pap gun";
go.SetParent( GameObject );
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
go.LocalRotation = GunAngles.ToRotation();
_gunRenderer = go.Components.Create<ModelRenderer>();
_gunGO = go;
// ⛔ MODEL FIRST, THEN POSITION — GunLocalPos reads the renderer's current
// model to centre the mesh, so placing first would align this gun using
// whatever came before it. Three separate paths in the mystery box had this
// bug; it is the same shape here and worth not re-learning.
// ⚠️ Born at the OUT position — the travel starts from where the player
// handed it over, not from inside the machine. Spawning it already in the
// throat makes the insert look like it began before you pressed the key.
if ( model is not null ) _gunRenderer.Model = model;
go.LocalPosition = GunLocalPos( OutPos );
_gunRenderer.Enabled = model is not null;
_sinceStage = 0f;
}
/// <summary>
/// Put the camo for the level being bought onto the machine's display gun.
///
/// ⚠️ `_newLevel`, NOT the weapon's current level. Insert() sets it to
/// `PapLevelFor( prefab ) + 1` — the tier the player is paying FOR — and that is what should
/// come out of the machine. Using the current level would show MK1's camo on the gun that is
/// about to become MK2, i.e. always exactly one tier stale.
///
/// ⚠️ HONOURS `PapCamo.CamoEnabled` like the held weapon does. `nz_camo 0` turning the gun in
/// your hands plain while the machine still hands you a painted one would make the toggle look
/// broken; the two paths must agree.
///
/// ⚠️ MaterialOverride REPLACES EVERY SURFACE, which is the same trade PapCamo.Repaint makes on
/// the view and world models — a scope's lens gets painted too. Matching the held weapon's
/// appearance is the point, so the display gun takes the identical compromise rather than a
/// per-submaterial treatment that would look different from what you receive.
/// </summary>
void PaintGun()
{
if ( !_gunRenderer.IsValid() ) return;
_gunRenderer.MaterialOverride = NZombies.PapCamo.CamoEnabled
? NZombies.PapCamo.MaterialFor( _newLevel )
: null;
}
void ClearGun()
{
_gunGO?.Destroy();
_gunGO = null;
_gunRenderer = null;
}
TimeSince _sinceStage;
/// <summary>Presented: out in front of the machine, at working height.</summary>
Vector3 OutPos => Vector3.Up * GunHeight + Vector3.Forward * GunOutDistance;
/// <summary>Being worked on: back in the roller throat.</summary>
Vector3 InPos => Vector3.Up * GunHeight - Vector3.Forward * GunInDepth;
/// <summary>
/// Move and turn the weapon for whatever stage the machine is in.
///
/// ⛔ RUNS OUTSIDE THE STAGE GATE, like every other per-frame motion in this
/// project. The gate in OnUpdate fires only on the frame a stage ENDS — a
/// travel driven from inside it is a weapon that teleports once.
///
/// ⚠️ The insert also swings the weapon through 90 degrees of yaw, which is the
/// original's own motion (`pap_weapon_fly` lerps a -90 -> 0 yaw offset in, and
/// back out on eject). It reads as the machine taking the gun rather than the
/// gun sliding on rails.
/// </summary>
void TickGun()
{
if ( !_gunGO.IsValid() ) return;
var pose = GunAngles.ToRotation();
switch ( State )
{
case PapState.Inserting:
{
float t = Smooth( _sinceStage / EffectiveTravel );
Place( Vector3.Lerp( OutPos, InPos, t ),
pose * Rotation.FromYaw( MathX.Lerp( -90f, 0f, t ) ) );
break;
}
case PapState.Working:
// ⛔ IT DOES NOT SPIN. It used to turn at 220 deg/s while the machine
// worked, which reads as a prize on a pedestal — the mystery box
// already learned the same lesson about its offer. The machine is
// doing the work; the weapon sits in the throat and waits.
Place( InPos, pose );
break;
case PapState.Ejecting:
{
float t = Smooth( _sinceStage / EffectiveTravel );
Place( Vector3.Lerp( InPos, OutPos, t ),
pose * Rotation.FromYaw( MathX.Lerp( 90f, 0f, t ) ) );
break;
}
case PapState.Ready:
{
// ⛔ THE GRAB WINDOW **IS** THE WEAPON SINKING BACK IN, exactly as the
// mystery box works. It used to hang motionless at the presented
// position for the full 20s and then vanish, so there was no way to
// tell four seconds left from eighteen — the deadline existed only as
// a hidden timer. Watching it go is the timer.
//
// ⚠️ LINEAR, unlike the insert and eject travels. Those are flourishes
// and get smoothstep; this is a clock, and a clock that speeds up or
// slows down misreports the one thing it exists to communicate.
float t = MathX.Clamp( _sinceStage / MathF.Max( ReadyTime, 0.01f ), 0f, 1f );
Place( Vector3.Lerp( OutPos, InPos, t ), pose );
break;
}
}
}
/// <summary>Smoothstep. The original eases its rotation the same way.</summary>
static float Smooth( float t )
{
t = MathX.Clamp( t, 0f, 1f );
return t * t * (3f - 2f * t);
}
/// <summary>
/// Set the pose, compensating the mesh offset LAST.
///
/// ⚠️ Rotation first, because the mesh-centre correction is rotated by whatever
/// pose the object is wearing — computing the offset against the previous
/// rotation puts the gun a little further out of place the faster it is turning.
/// </summary>
void Place( Vector3 at, Rotation rot )
{
_gunGO.LocalRotation = rot;
_gunGO.LocalPosition = GunLocalPos( at, rot );
}
/// <summary>
/// Local position that puts the gun's MESH at <see cref="GunHeight"/>.
///
/// ⚠️ Viewmodels are authored around a camera and a pair of hands, so their
/// geometry sits anywhere relative to their origin — measured across our pool,
/// 29 of 31 fall in a 5.4u band and the Uzi and ASP are ~50 units up. Placing
/// by origin would hang those two through the machine's ceiling.
/// </summary>
Vector3 GunLocalPos( Vector3 at ) => GunLocalPos( at, GunAngles.ToRotation() );
Vector3 GunLocalPos( Vector3 at, Rotation rot )
{
var model = _gunRenderer.IsValid() ? _gunRenderer.Model : null;
if ( model is null ) return at;
return at - rot * model.Bounds.Center;
}
/// <summary>
/// A weapon prefab's viewmodel.
///
/// ⚠️ SAME TWO-CANDIDATE LOOKUP AS THE BOX, and for the same reason: 31 of the
/// 32 folders drop the `nz_` prefix and the MPL keeps it
/// (`weapons/nz_mpl/v_nz_mpl.vmdl`). Stripping unconditionally makes exactly
/// one weapon invisible, which nobody notices until it is the one they packed.
/// </summary>
public static Model ModelForPrefab( string prefab )
{
if ( string.IsNullOrWhiteSpace( prefab ) ) return null;
var raw = prefab;
var slash = raw.LastIndexOf( '/' );
if ( slash >= 0 ) raw = raw[(slash + 1)..];
raw = raw.Replace( ".prefab", "" );
var bare = raw.StartsWith( "nz_" ) ? raw[3..] : raw;
foreach ( var stem in new[] { bare, raw } )
{
// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`): an MW viewmodel's bind pose floats in pieces
var m = WeaponDisplay.Load( $"weapons/{stem}/v_{stem}.vmdl" );
if ( m is not null && !m.IsError ) return m;
}
return null;
}
/// <summary>A weapon's display name, from the library.</summary>
public static string WeaponName( string prefab )
{
var entry = WeaponLibrary.All.FirstOrDefault( e => e.Prefab == prefab );
// ⚠️ THE PRISMA IS NOT IN THE LIBRARY (WeaponLibrary leaves the egg's weapon out), so it
// names itself here rather than printing its prefab path.
return entry?.Name ?? (BuildParts.IsWonderWeapon( prefab ) ? BuildParts.WeaponName : prefab);
}
/// <summary>
/// Take the weapon off the player.
///
/// ⚠️ THE SAME disable-unparent-destroy SEQUENCE WallBuy AND THE BOX USE. A
/// Destroy is deferred to end of frame, so the equip guard would otherwise
/// still find the old weapon and silently skip spawning the replacement.
/// </summary>
static void StripWeapon( NZPlayer player )
{
// ⛔ ONLY THE WEAPON BEING PACKED, NOT EVERY WEAPON. This destroyed all of
// them, which was right with one slot and now throws away the gun you did
// not put in the machine — insert your pistol and your Galil vanished.
//
// ⚠️ Through the inventory so `Items` is updated too. Destroying the object
// behind the inventory's back leaves a dead entry in a slot, which reads as
// a phantom weapon you cannot switch to.
var inv = player.Inventory;
if ( inv.Active.IsValid() ) inv.Remove( inv.Active );
}
/// <summary>The nearest machine a point could use, or null.</summary>
public static PackAPunch Near( Vector3 point )
{
PackAPunch best = null;
float bestDist = float.MaxValue;
foreach ( var m in All )
{
if ( !m.IsValid() ) continue;
float d = m.WorldPosition.Distance( point );
if ( d > m.Reach || d >= bestDist ) continue;
bestDist = d;
best = m;
}
return best;
}
}