EasterEgg/Shootable.cs

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.

NetworkingFile Access
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&lt;IDamageable&gt;()` 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;
	}
}