A game component representing an Easter-egg shootable target. It tracks all instances, responds to bullet damage (IDamageable.OnDamage), enforces availability rules (including Pack-a-Punch requirements), debounces one hit per frame, bankers hits toward a repeat count, and provides diagnostic helpers like nearest lookup and a console-simulate method.
using Sandbox;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// AN EASTER-EGG SHOOTABLE — a symbol, weak point or switch triggered by being SHOT.
///
/// The spec files it as "same conditions as Pressable" minus the hold, so every condition it
/// carries lives in <see cref="EggInteractable"/> and what is left here is the trigger: a
/// bullet, and the questions only a bullet can answer — what fired it, and whether that gun was
/// packed.
///
/// ⛔ IT IS AN `IDamageable`, WHICH IS HOW THE BULLET FINDS IT. `BulletInfo.HitScan` resolves
/// `hitObj.Components.GetInAncestorsOrSelf<IDamageable>()` and calls `OnDamage` on it — the
/// same door a zombie's `Health` comes through. No `Health` component, no hit points: this is
/// not a thing that can be killed, it is a thing that notices.
/// </summary>
public sealed class Shootable : EggInteractable, Component.IDamageable
{
/// <summary>Every live shootable, for the diagnostics.</summary>
public static readonly List<Shootable> All = new();
protected override void OnEnabled()
{
base.OnEnabled();
if ( !All.Contains( this ) ) All.Add( this );
}
protected override void OnDisabled()
{
base.OnDisabled();
All.Remove( this );
}
/// <summary>The config row this was built from.</summary>
[Property] public ShootableSpot Spot { get; set; }
/// <summary>The shared fields, for <see cref="EggInteractable"/>.</summary>
public override EggSpot Config => Spot;
/// <summary>Hits banked toward <see cref="EggSpot.RepeatCount"/>.</summary>
public int Hits => Progress;
/// <summary>How far away `nz_shoot_where` will look for one. Nothing about the shootable
/// itself is range-limited — a bullet arrives from wherever it arrives.</summary>
public const float FindRange = 400f;
// ⛔ ONE HIT PER FRAME. A shotgun resolves eight pellets in a single trigger pull and every
// one of them calls OnDamage — which on a repeat-count target would bank eight, overshoot,
// and fail the step from one shot the player thought was correct. Stamping the frame is
// exact where a time window is a guess: an 900rpm gun fires every fourth frame at 60fps, so
// nothing legitimate is swallowed.
float _hitFrame = -1f;
// ⚠️ REFUSALS ARE THROTTLED, NOT SILENT. Holding the trigger on a locked target would
// otherwise write a hundred identical lines a second; saying nothing at all would leave "I
// shot it and nothing happened" with no trace anywhere.
float _refusedAt = -1f;
/// <summary>One line for the group diagnostics.</summary>
public override string Describe()
=> $"target at {WorldPosition:0}"
+ ( Spot is not null && Spot.RepeatCount > 1 ? $" (x{Spot.RepeatCount})" : "" );
/// <summary>The nearest shootable that is still there, or null.</summary>
public static Shootable Near( Vector3 pos ) => Near( pos, false );
/// <summary>The nearest shootable INCLUDING finished ones — for the diagnostics.</summary>
public static Shootable NearAny( Vector3 pos ) => Near( pos, true );
static Shootable Near( Vector3 pos, bool includeDone )
{
Shootable best = null;
var bestDist = FindRange;
foreach ( var s in All )
{
if ( !s.IsValid() ) continue;
if ( !includeDone && s.Spot?.Step?.Completed == true ) continue;
var d = pos.Distance( s.WorldPosition );
if ( d > bestDist ) continue;
bestDist = d;
best = s;
}
return best;
}
/// <summary>
/// A bullet landed on it.
///
/// ⛔ BULLETS ONLY, TESTED BY `damage.Weapon` BEING SET. `DamageInfo.FromBullet` is the only
/// thing in this project that fills that field — the knife, grenades, traps and status
/// effects hand-build their DamageInfo and leave it null. So the test is not a heuristic: it
/// is the difference between "shot" and "damaged", and a shootable that a grenade could set
/// off would make every weapon requirement on it meaningless.
/// </summary>
public void OnDamage( in DamageInfo damage )
{
if ( Spot is null ) return;
if ( !damage.Weapon.IsValid() ) return;
if ( _hitFrame == Time.Now ) return;
_hitFrame = Time.Now;
var player = damage.Attacker.IsValid()
? damage.Attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
if ( !player.IsValid() ) return;
var weapon = damage.Weapon.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
var blocked = Unavailable( player, weapon );
if ( !string.IsNullOrEmpty( blocked ) )
{
if ( Time.Now - _refusedAt > 1f )
{
_refusedAt = Time.Now;
Log.Info( $"[nz-ee] shootable ignored a hit — {blocked}" );
}
return;
}
Log.Info( $"[nz-ee] {Bank()}" );
}
/// <summary>
/// The shared gates plus the one only a shootable has.
///
/// ⚠️ THE PACK-A-PUNCH TEST READS THE GUN THAT FIRED, THROUGH ITS PREFAB. `PapLevels` is
/// keyed on the prefab path — `Rarity.PrefabOf` is the established weapon-to-prefab mapping
/// — because a live weapon's DisplayName carries a "MK2" suffix and is a lossy join.
/// </summary>
public override string Unavailable( NZPlayer player, SWB.Base.Weapon weapon )
{
var blocked = base.Unavailable( player, weapon );
if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
if ( Spot.RequiresPaP && !IsPacked( player, weapon ) )
return "Needs a Pack-a-Punched weapon";
return "";
}
/// <summary>Has this weapon been through the Pack-a-Punch, at any tier?</summary>
public static bool IsPacked( NZPlayer player, SWB.Base.Weapon weapon )
{
if ( !player.IsValid() || !weapon.IsValid() ) return false;
var prefab = Rarity.PrefabOf( weapon );
return !string.IsNullOrEmpty( prefab ) && player.PapLevelFor( prefab ) > 0;
}
/// <summary>
/// Shoot it from the console, as the local player's current weapon.
///
/// ⚠️ IT GOES THROUGH THE SAME `Unavailable` AND `Bank` A BULLET DOES, so the repeat count,
/// the overshoot rule, the step clock and every gate are on the path being tested. What it
/// cannot reproduce is the bullet itself — so a weapon requirement is judged against the gun
/// in hand, which is what a real shot would have used anyway.
/// </summary>
public string Simulate( NZPlayer player )
{
var weapon = HeldWeapon( player );
var blocked = Unavailable( player, weapon );
return string.IsNullOrEmpty( blocked ) ? Bank() : blocked;
}
}