Abstract component for an Easter-egg interactable, implementing shared state and rules used by concrete triggers (pressable, shootable, etc.). It manages progress counting, cooldown/round lockouts, timed availability, visibility toggling, group coordination via EggGroups, and provides availability text and failure handling.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE SHARED BODY OF AN EASTER-EGG INTERACTABLE — the conditions every one of them carries,
/// in one place.
///
/// ⛔ THIS EXISTS BECAUSE THE SECOND INTERACTABLE WOULD OTHERWISE HAVE COPIED THE FIRST. The
/// spec files the Shootable as "same conditions as Pressable" minus the hold, so building it
/// standalone meant a second copy of the repeat count, the overshoot rule, the retry lockout,
/// the round reset, the timed delay, the group wiring and the vanish-on-completion. Two copies
/// of a condition engine is exactly the shape `INSTRUCTIONS.md` §3 records diverging: one of
/// them gets the fix. There are seven more interactables in the spec.
///
/// ⚠️ WHAT STAYS IN THE SUBCLASS IS THE TRIGGER, and only that. A pressable is triggered by
/// the use key and can be held; a shootable is triggered by a bullet and knows what fired it.
/// Everything between "something happened" and "the step moved" is here.
/// </summary>
public abstract class EggInteractable : Component, IEggInteractable
{
/// <summary>The config row, as the shared fields. Each subclass returns its own typed
/// spot; every condition below is read through this.</summary>
public abstract EggSpot Config { get; }
/// <summary>The step this carries — <see cref="IEggInteractable"/>.</summary>
public EggStep Step => Config?.Step;
/// <summary>Seconds to finish the step once started — <see cref="IEggInteractable"/>.</summary>
public float TimeWindow => Config?.TimeWindow ?? 0f;
/// <summary>
/// The child object holding the model and the collider — everything a player can see or
/// bump into or shoot. Set by the manager at build time.
///
/// ⚠️ ONE OBJECT, NOT A RENDERER AND A COLLIDER SEPARATELY. Switching the child off takes
/// both with it, so a finished interactable cannot be seen, shot, or walked into — one
/// that is invisible but still solid is worse than one that never vanished.
/// </summary>
[Property] public GameObject Visual { get; set; }
/// <summary>Triggers banked toward <see cref="EggSpot.RepeatCount"/>.</summary>
public int Progress { get; protected set; }
/// <summary>Seconds left on a failed-attempt cooldown. Only used under
/// <see cref="EggRetry.Seconds"/>.</summary>
public float Cooldown { get; private set; }
/// <summary>Locked out until the round turns, after failing under
/// <see cref="EggRetry.NextRound"/>.
///
/// ⚠️ A FLAG, NOT A HUGE `Cooldown` VALUE. Expressing "until the next round" as a number of
/// seconds would need a guess at how long a round lasts, and would be wrong every time the
/// players cleared one faster or slower than the guess.</summary>
public bool LockedUntilRound { get; private set; }
/// <summary>When this step's Required flags first became satisfied — the clock the Timed
/// condition counts from. Negative = they never have been.</summary>
protected float AvailableSince = -1f;
// ⚠️ REGISTERED WITH THE GROUP COORDINATOR IN THE BASE, so a new interactable joins the
// step system by existing rather than by remembering to. A member that outlived its
// GameObject would leave a group that could never finish.
protected override void OnEnabled() => EggGroups.Register( this );
protected override void OnDisabled() => EggGroups.Forget( this );
protected override void OnUpdate()
{
if ( Config is null ) return;
TickVisibility();
// ⚠️ EVERY MEMBER CALLS IT AND THE COORDINATOR RUNS IT ONCE. There is no component
// behind `EggGroups`, so the step clock has to be driven by whatever is alive — and the
// things that are alive are its own members.
EggGroups.Tick();
var dt = Time.Delta;
if ( Cooldown > 0f ) Cooldown = MathF.Max( 0f, Cooldown - dt );
// ⚠️ THE TIMED CLOCK STARTS WHEN `Required` IS FIRST SATISFIED, not when the map loads.
// "Unlocks X seconds after its Required flags are set" is only meaningful measured from
// that moment; counting from load would make a step gated behind an hour of play
// available the instant its flags landed.
var gated = EggFlags.AllSet( Step.Required ) && !EggFlags.AnySet( Step.Excluded );
if ( gated && AvailableSince < 0f ) AvailableSince = Time.Now;
else if ( !gated ) AvailableSince = -1f;
OnTick( dt );
}
/// <summary>Per-frame work only this kind of interactable has — the pressable's hold. Most
/// have none.</summary>
protected virtual void OnTick( float dt ) { }
/// <summary>
/// A completed step is GONE from the world — its parts stop being there once the step is
/// done.
///
/// ⛔ STATE-DRIVEN EVERY FRAME, NOT AN EVENT ON COMPLETION. The same choice the damage wall
/// was rebuilt around: hiding on a "step completed" callback means every other way the state
/// can move — a group reset, `Fail` with RevertToPrevious, `nz_egg_reset`, a config rebuild
/// — needs its own matching un-hide, and the one that gets forgotten leaves a part missing
/// from a map with nothing to explain it. Reading the state instead means one rule that
/// cannot fall out of step.
///
/// ⚠️ A GROUP VANISHES TOGETHER FOR FREE, because every member completes in the same call.
///
/// ⚠️ CREATIVE KEEPS THEM. A mapper testing their own egg still has to see the parts to move
/// or delete them, and `ShowAuthoringVisuals` is off in preview, so a preview still shows
/// what a player would see.
/// </summary>
protected void TickVisibility()
{
if ( !Visual.IsValid() ) return;
var show = !Step.Completed || NZGame.ShowAuthoringVisuals;
if ( Visual.Enabled != show ) Visual.Enabled = show;
}
/// <summary>
/// Why this cannot be triggered right now, or empty when it can. Answers for the prompt and
/// for the trigger, so a prompt can never offer what the trigger then refuses.
/// </summary>
public string Unavailable( NZPlayer player ) => Unavailable( player, HeldWeapon( player ) );
/// <summary>
/// The same question, told which weapon to judge.
///
/// ⛔ THE WEAPON IS A PARAMETER BECAUSE THE TWO TRIGGERS MEAN DIFFERENT THINGS BY IT. A
/// pressable asks about the gun in your hands right now; a shootable asks about the gun the
/// BULLET came from, which with a fast switch is not the same object. Resolving it inside
/// would silently make the shootable read the wrong one.
/// </summary>
public virtual string Unavailable( NZPlayer player, SWB.Base.Weapon weapon )
{
if ( !player.IsValid() || Config is null ) return "no player";
// ⛔ A COMPLETED STEP REFUSES, IT DOES NOT RETURN "". Empty means "go ahead" to the
// trigger, and this line used to return it — so a finished button could be pressed for
// ever, answering "DONE — set flag_c" every time while setting nothing. It went
// unnoticed because `EggStep.Complete` early-outs, so the only symptom was a lie in the
// reply. `UsePrompt` tests `Completed` itself before asking, so the prompt still shows
// nothing on a finished one.
if ( Step.Completed ) return "Already done";
// ⚠️ A SATISFIED MEMBER SAYS SO RATHER THAN GOING SILENT. It has been done correctly and
// is waiting on the rest of its step; without this it reads exactly like something that
// stopped working, and the player's own correct input is what broke it.
if ( Step.Satisfied )
return $"Done — waiting on the rest of step {Step.StepNumber}";
// ⚠️ THE DOOR FLAG IS CHECKED HERE, NOT IN `EggStep.IsAvailable` — see `EggSpot.Link`.
// It is the General pool, which the spec keeps out of the egg's own inputs, so it sits
// at the interaction layer beside the perk and weapon checks.
//
// ⚠️ IT GETS ITS OWN WORD. "Locked" and "Nothing to do here" look identical from in
// front of an unresponsive object but are different causes — one is waiting on a door
// being bought, the other on the egg reaching this step.
if ( !DoorLinks.IsOpen( Config.Link ) ) return "Locked";
if ( !Step.IsAvailable() ) return "Nothing to do here";
if ( Cooldown > 0f ) return $"Wait {Cooldown:0.0}s";
// ⚠️ SAYS THE ROUND, NOT A TIME. There is no number to show — that is the whole point of
// the setting — and a spinner counting down to nothing would be worse than the truth.
if ( LockedUntilRound ) return "Wait for the next round";
if ( Config.TimedDelay > 0f )
{
if ( AvailableSince < 0f ) return "Nothing to do here";
var waited = Time.Now - AvailableSince;
if ( waited < Config.TimedDelay )
return $"Wait {Config.TimedDelay - waited:0.0}s";
}
if ( !string.IsNullOrWhiteSpace( Config.RequiredPerk ) && !player.HasPerk( Config.RequiredPerk ) )
return $"Needs {PerkRegistry.All.FirstOrDefault( p => p.Id == Config.RequiredPerk )?.Name ?? Config.RequiredPerk}";
if ( !string.IsNullOrWhiteSpace( Config.RequiredWeapon ) && !Matches( weapon, Config.RequiredWeapon ) )
return $"Needs {Config.RequiredWeapon}";
return "";
}
/// <summary>The weapon in the player's hands, or null.</summary>
protected static SWB.Base.Weapon HeldWeapon( NZPlayer player )
{
var held = player.IsValid()
? player.Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active
: null;
return held.IsValid() ? held.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf ) : null;
}
/// <summary>Does this weapon answer to the name a mapper typed?
///
/// ⚠️ MATCHED ON THE CLASS NAME, CASE-INSENSITIVELY AND BY SUBSTRING, so "awm" matches
/// "nz_awm" and a mapper does not have to know the prefab prefix.</summary>
protected static bool Matches( SWB.Base.Weapon weapon, string wanted )
{
var cls = weapon.IsValid() ? weapon.ClassName : null;
return !string.IsNullOrEmpty( cls )
&& cls.Contains( wanted, StringComparison.OrdinalIgnoreCase );
}
/// <summary>
/// Count a trigger that landed and decide what it means.
///
/// ⛔ OVERSHOOT FAILS, IT DOES NOT CAP. The spec: "Overshooting past N invalidates the
/// current attempt, it does not cap at max." Clamping would make a repeat-count target
/// impossible to get wrong, which is the entire puzzle.
/// </summary>
protected string Bank()
{
NZSound.Play( NZSound.Purchase, WorldPosition );
var need = Math.Max( 1, Config.RepeatCount );
Progress++;
if ( Progress > need ) return FailAttempt( $"overshot ({Progress}/{need})" );
// ⛔ THE STEP CLOCK STARTS HERE, ON A TRIGGER THAT COUNTED — not on the first member to
// finish. With four statues of three presses each, the first of those twelve presses is
// what starts the step; anything later would give a free run-up.
EggGroups.NoteProgress( this );
if ( Progress < need )
return $"{Progress}/{need}";
Progress = 0;
// ⛔ THE GROUP DECIDES, NOT THE PART. `EggGroups.Satisfy` completes an ungrouped step on
// the spot and holds a grouped one until its last sibling lands — no interactable should
// know which of those it is, or grouping would be re-implemented in every one of them.
var fired = EggGroups.Satisfy( this );
if ( !fired )
{
var left = EggGroups.InGroup( Step.StepNumber ).Count( m => !m.Step.Satisfied );
return $"DONE — waiting on {left} more in step {Step.StepNumber}";
}
var rewards = Step.Reward.Count > 0 ? string.Join( ", ", Step.Reward ) : "no flag";
return $"DONE — set {rewards}"
+ ( string.IsNullOrWhiteSpace( Step.GeneralReward )
? "" : $" · opened {Step.GeneralReward}" )
+ ( Step.StepNumber > 0 ? $" · step {Step.StepNumber} complete" : "" );
}
/// <summary>Fail the current attempt: zero the progress, start the retry lockout, and let
/// the step's own OnFail decide whether prior steps re-open.</summary>
protected string FailAttempt( string why )
{
Progress = 0;
ClearOwnProgress();
// ⛔ ONE OR THE OTHER, NEVER BOTH. `EggRetry` exists so a mapper answers "how is a
// failure locked out" once; setting a clock as well under NextRound would make it
// retry-able the moment the clock ran out, silently ignoring the choice.
if ( Config.Retry == EggRetry.NextRound )
{
LockedUntilRound = true;
Cooldown = 0f;
}
else
{
Cooldown = MathF.Max( 0f, Config.CooldownSeconds );
LockedUntilRound = false;
}
Step.Fail();
return $"FAILED — {why}"
+ ( LockedUntilRound ? " · retry next round" : "" )
+ ( Cooldown > 0f ? $" · retry in {Cooldown:0.#}s" : "" );
}
/// <summary>Fail this member on the group's behalf — <see cref="IEggInteractable"/>. It goes
/// through the same `FailAttempt` a wrong trigger does, so the retry lockout and
/// `OnFail: RevertToPrevious` behave identically however the failure arrived.</summary>
public void Fail( string why ) => FailAttempt( why );
/// <summary>Zero everything this holds. Returns true if there was anything to zero.</summary>
public bool ClearProgress()
{
var had = Progress > 0 || LockedUntilRound || HasOwnProgress;
Progress = 0;
ClearOwnProgress();
// ⚠️ THE LOCK GOES TOO. A group being wound back is being handed to the players again
// from scratch; leaving one member still serving a lockout would make the reset look
// like it had missed one.
LockedUntilRound = false;
return had;
}
/// <summary>Partial state only this kind holds — the pressable's hold timer.</summary>
protected virtual bool HasOwnProgress => false;
/// <summary>Drop that state.</summary>
protected virtual void ClearOwnProgress() { }
/// <summary>One line for the group diagnostics.</summary>
public abstract string Describe();
/// <summary>
/// Round rollover, for every interactable at once.
///
/// ⚠️ CALLED FROM `RoundManager.BeginRound`, beside `AmmoBox.OnRoundStart`. The spec says
/// "resets at END of round"; the start of the next one is the same boundary and is where
/// this project already has a hook, so there is no second notion of when a round turns over.
///
/// ⚠️ IT IS NOT A `Fail`. Progress goes to 0 and the step stays available — running out of
/// round is not the same as triggering wrong, and calling Fail would fire RevertToPrevious
/// and unwind earlier steps for simply taking too long.
///
/// ⛔ IT WALKS `EggGroups.All`, NOT A PER-TYPE LIST. Every interactable registers there by
/// existing, so a new kind is covered by the round rules the day it is written rather than
/// the day somebody remembers to add a second hook in `RoundManager`.
/// </summary>
public static void OnRoundStart()
{
int zeroed = 0, unlocked = 0;
foreach ( var i in EggGroups.All.OfType<EggInteractable>().ToList() )
{
if ( !i.IsValid() || i.Config is null ) continue;
// ⛔ THE ROUND LOCK LIFTS FOR EVERYONE, BEFORE THE `ResetOnRound` TEST. It is not
// part of the Round condition — it is the retry rule, and one whose mapper never
// ticked "Reset each round" would otherwise stay locked out of the egg for ever
// after a single wrong trigger.
if ( i.LockedUntilRound )
{
i.LockedUntilRound = false;
unlocked++;
}
if ( !i.Config.ResetOnRound || i.Step.Completed ) continue;
if ( i.ClearProgress() ) zeroed++;
}
if ( unlocked > 0 )
Log.Info( $"[nz-ee] {unlocked} interactable(s) came off their round lockout" );
if ( zeroed > 0 )
Log.Info( $"[nz-ee] {zeroed} interactable(s) reset on the round turn" );
}
}