EasterEgg/Pressable.cs

A game component representing a pressable Easter-egg interactable (button/lever). It tracks all live instances, handles starting and updating a hold interaction (including range, movement and damage cancellation), banks presses for repeatable spots, and provides nearest-item lookup.

File Access
using Sandbox;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// AN EASTER-EGG PRESSABLE — a button or lever. Walk up, press E.
///
/// ⚠️ EVERY CONDITION IT CARRIES LIVES IN <see cref="EggInteractable"/>. What is left here is
/// the TRIGGER and nothing else: the use key, and the hold that only a use key can have.
///
/// ⚠️ THE SHAPE IS THE AMMO BOX'S — `All` / `Near` / `Unavailable` / a use method, with
/// `Unavailable` answering for both the prompt and the key. Every walk-up interaction in this
/// project is written that way so the prompt can never offer what E then refuses.
/// </summary>
public sealed class Pressable : EggInteractable
{
	/// <summary>Every live pressable, for the use trace and the diagnostics.</summary>
	public static readonly List<Pressable> 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 PressableSpot Spot { get; set; }

	/// <summary>The shared fields, for <see cref="EggInteractable"/>.</summary>
	public override EggSpot Config => Spot;

	/// <summary>How close you must stand. Matches the ammo box and Pack-a-Punch.</summary>
	public const float UseRange = 90f;

	/// <summary>Presses banked toward <see cref="EggSpot.RepeatCount"/>. The base calls it
	/// Progress; a button's progress is presses.</summary>
	public int Presses => Progress;

	/// <summary>How long the current hold has run. 0 when not holding.</summary>
	public float HeldFor { get; private set; }

	NZPlayer _holder;
	Vector3 _holdFrom;
	float _holderHealth;

	/// <summary>One line for the group diagnostics.</summary>
	public override string Describe()
		=> $"button at {WorldPosition:0}"
			+ ( Spot is not null && Spot.RepeatCount > 1 ? $" (x{Spot.RepeatCount})" : "" );

	/// <summary>
	/// The nearest pressable that is still THERE, or null.
	///
	/// ⛔ A COMPLETED ONE IS SKIPPED, not merely refused. It has been removed from the world,
	/// and something that is not there must not be the nearest thing — otherwise a finished
	/// button standing in front of a live one would silently swallow its prompt. The
	/// diagnostics want the opposite and use <see cref="NearAny"/>.
	/// </summary>
	public static Pressable Near( Vector3 pos ) => Near( pos, false );

	/// <summary>The nearest pressable INCLUDING finished ones — for `nz_press_where`, which has
	/// to be able to talk about a button that has already been used.</summary>
	public static Pressable NearAny( Vector3 pos ) => Near( pos, true );

	static Pressable Near( Vector3 pos, bool includeDone )
	{
		Pressable best = null;
		var bestDist = UseRange;

		foreach ( var p in All )
		{
			if ( !p.IsValid() ) continue;
			if ( !includeDone && p.Spot?.Step?.Completed == true ) continue;

			var d = pos.Distance( p.WorldPosition );
			if ( d > bestDist ) continue;

			bestDist = d;
			best = p;
		}

		return best;
	}

	/// <summary>The hold is the only per-frame work a button has.</summary>
	protected override void OnTick( float dt ) => TickHold( dt );

	/// <summary>
	/// Advance or cancel a hold.
	///
	/// ⛔ THE HOLD IS DRIVEN FROM HERE, NOT FROM THE USE KEY. `NZPlayer.TickUse` fires on a
	/// PRESS; a hold has to survive across frames and be cancellable by things that are nothing
	/// to do with the key — moving, taking a hit, walking out of range. Driving it from the key
	/// would make "let go" the only way to cancel.
	/// </summary>
	void TickHold( float dt )
	{
		if ( _holder is null ) return;

		if ( !_holder.IsValid() || Spot.HoldSeconds <= 0f ) { CancelHold( "" ); return; }

		// ⚠️ RANGE IS RE-CHECKED EVERY FRAME. Starting a hold and walking away would otherwise
		// complete it from across the map.
		if ( _holder.WorldPosition.Distance( WorldPosition ) > UseRange )
		{ CancelHold( "moved out of range" ); return; }

		if ( Spot.CancelOnMove && _holder.WorldPosition.Distance( _holdFrom ) > 8f )
		{ CancelHold( "moved" ); return; }

		if ( Spot.CancelOnDamage )
		{
			var hp = _holder.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );

			// ⚠️ COMPARED AGAINST THE HEALTH AT THE START OF THE HOLD, not against a damage
			// event. There is no "was I hit this frame" flag to read, and a drop in current
			// health is the same question asked in a way that cannot miss one.
			if ( hp.IsValid() && hp.Current < _holderHealth - 0.01f )
			{ CancelHold( "took damage" ); return; }
		}

		HeldFor += dt;

		if ( HeldFor >= Spot.HoldSeconds )
		{
			ClearHold();
			Log.Info( $"[nz-ee] {Bank()}" );
		}
	}

	void CancelHold( string why )
	{
		var had = _holder is not null;
		ClearHold();

		if ( had && !string.IsNullOrEmpty( why ) )
			Log.Info( $"[nz-ee] pressable hold cancelled — {why}" );
	}

	void ClearHold() { _holder = null; HeldFor = 0f; }

	/// <summary>A hold in progress is progress — the base has to know, so a group reset zeroes
	/// it and so a round turn counts it.</summary>
	protected override bool HasOwnProgress => _holder is not null || HeldFor > 0f;

	protected override void ClearOwnProgress() => ClearHold();

	/// <summary>
	/// The use key. Starts a hold, or banks a press.
	/// </summary>
	public string Press( NZPlayer player )
	{
		var blocked = Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		if ( Spot.HoldSeconds > 0f )
		{
			var hp = player.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );

			_holder = player;
			_holdFrom = player.WorldPosition;
			_holderHealth = hp.IsValid() ? hp.Current : float.MaxValue;
			HeldFor = 0f;

			return $"holding… ({Spot.HoldSeconds:0.#}s)";
		}

		return Bank();
	}
}