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.
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();
}
}