A Razor UI component (CluePanel) in NZombies that renders a clue's image and text into the world. It reads the Clue component beside it for Spot data, shows either a static PNG or a live-generated texture, handles fade transitions and sets text style and opacity.
@using Sandbox;
@using Sandbox.UI;
@using NZombies;
@inherits PanelComponent
@*
⚠️ `@namespace NZombies` IS REQUIRED, not decoration. Without it a razor compiles into
the GLOBAL namespace and `ClueManager` — which is in `NZombies` — cannot see the type
at all (CS0246). `WunderfizzPanel` and `ArsenalPanel` are the two razors already
referenced from C# and both carry it; the HUD razors do not, because only other razors
touch them.
*@
@namespace NZombies
@*
CLUE PANEL — the text/image a clue shows, rendered into the world by the
`WorldPanel` on the same GameObject.
⚠️ IT READS THE `Clue` BESIDE IT rather than carrying its own copy of the text.
`ClueManager` sets `Clue.Spot` and nothing else; one source, so a clue retuned over
the console redraws without the manager having to push the new string in.
⛔ A CLUE IS ALWAYS VISIBLE — no proximity check, no authoring gate. The spec says
"always visible, static", and it is the one EE tool whose whole job is to be read
from wherever the player happens to be standing. Hiding it outside creative would
make it useless in the run it exists for.
*@
<root class="clue">
@if ( HasImage && Live )
{
<div class="img" @ref="LiveImg" style="opacity: @Alpha;"></div>
}
else if ( HasImage )
{
<div class="img" style="background-image: url( @Image ); opacity: @Alpha;"></div>
}
@if ( !string.IsNullOrWhiteSpace( Spot?.Text ) )
{
<div class="txt" style="font-size: @(Spot.FontSize)px; color: @Rgb; opacity: @Alpha;">@Spot.Text</div>
}
</root>
@code
{
ClueSpot Spot => Components.Get<Clue>( FindMode.EverythingInSelf )?.Spot;
bool HasImage => !string.IsNullOrWhiteSpace( Image );
/// <summary>
/// The image as the engine can load it — a bare file name found in the clue folder (`ClueManager.ResolveImage`).
/// Worked out once per typed value rather than every frame, because finding it asks the filesystem.
/// </summary>
string Image
{
get
{
var typed = Spot?.Image;
if ( typed != _typed ) { _typed = typed; _resolved = ClueManager.ResolveImage( typed ); }
return _resolved;
}
}
string _typed, _resolved;
/// <summary>
/// Is this one of the clues drawn live rather than from its PNG (`LiveClues`) — basalt's hex map, with this round's
/// picks in their colours once the power is on, or its rings, with each dot where the puzzle stands?
/// </summary>
bool Live => LiveClues.Is( Image );
/// <summary>The live clue's picture panel, from the tree.</summary>
Panel LiveImg;
Texture _liveShown;
Panel _liveOn;
/// <summary>
/// ⚠️ THE LIVE CLUE'S PICTURE IS SET HERE, NOT IN THE MARKUP: it is a texture drawn in code, which no `url()` can
/// name. Asked every frame and cheap when nothing changed — `LiveClues.Current` hands back the same texture until what
/// it shows changes — and the style is only written when the texture or the panel is new.
/// </summary>
/// <summary>How far a live clue is faded in, 0-1: one gone (`LiveClues.Gone`) fades out, and back in when it returns.</summary>
float _fade = -1f;
Panel _fadeOn;
/// <summary>How long a live clue takes to fade out or back in, in seconds.</summary>
const float FadeSeconds = 1.5f;
protected override void OnUpdate()
{
if ( !Live || LiveImg is null ) return;
// ⛔ BASALT'S HEX MAP FADES AWAY ONCE COLOR RINGS IS DONE, and back at a new game (`LiveClues.Gone`). A panel met
// for the first time starts where it should be — gone or shown — rather than fading from wherever. The opacity is
// written only when it moves, or onto a panel the tree has made anew.
var want = LiveClues.Gone( Image ) ? 0f : 1f;
var was = _fade;
_fade = _fade < 0f ? want : _fade.Approach( want, Time.Delta / FadeSeconds );
if ( _fade != was || !ReferenceEquals( LiveImg, _fadeOn ) )
{
LiveImg.Style.Opacity = _fade * ( Spot?.Tint.a ?? 1f );
_fadeOn = LiveImg;
}
var tex = LiveClues.Current( Image );
if ( tex is null )
{
// it could not be drawn: the plain map from the PNG, set once
if ( !ReferenceEquals( LiveImg, _liveOn ) || _liveShown is not null )
LiveImg.Style.SetBackgroundImage( Image );
_liveShown = null;
_liveOn = LiveImg;
return;
}
if ( ReferenceEquals( tex, _liveShown ) && ReferenceEquals( LiveImg, _liveOn ) ) return;
LiveImg.Style.BackgroundImage = tex;
_liveShown = tex;
_liveOn = LiveImg;
}
/// <summary>
/// The text colour, FULLY OPAQUE. Transparency is applied separately — see
/// <see cref="Alpha"/>.
///
/// ⛔ THE ALPHA MUST NOT GO IN HERE, AND PUTTING IT HERE WAS A BUG. The text carries a
/// `text-stroke` outline whose colour is its own; fading only the FILL left the black
/// outline at full strength, so lowering opacity made the writing look darker and
/// heavier rather than fainter. Fill and outline have to fade together.
/// </summary>
string Rgb
{
get
{
var c = Spot?.Tint ?? Color.White;
return $"rgb({(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)})";
}
}
/// <summary>The whole element's opacity, which fades the fill and its outline as one.</summary>
string Alpha => ( Spot?.Tint.a ?? 1f ).ToString( "0.###" );
/// <summary>
/// ⚠️ THE HASH TRACKS THE CONTENT, unlike `DamageNumbersHud`'s constant one. A clue's
/// tree is rebuilt only when the text, the image or the size actually change — which
/// is when a mapper edits it — rather than every frame. The opposite choice there was
/// for elements repositioned continuously; nothing here moves.
/// </summary>
protected override int BuildHash()
=> System.HashCode.Combine( Spot?.Text, Spot?.Image, Spot?.FontSize, Spot?.Tint );
}