ClueManager component that creates and rebuilds in-game clue objects from configuration. It ensures a singleton, creates a non-saved GameObject per clue, positions and orients a WorldPanel and Clue components, resolves image paths for clue textures, and logs missing or invalid images.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Builds the config's clues.
///
/// ⚠️ SAME SHAPE AS EVERY OTHER PLACEABLE MANAGER — Ensure creates on demand, NotSaved
/// keeps it out of the map file, Rebuild is the single way anything gets built.
///
/// ⛔ AND IT ASKS NOTHING ABOUT THE MODE. A clue is always visible, in creative and in a
/// round alike; the damage wall's lesson was that a mode-dependent BUILD forces a Rebuild
/// on every mode change, and that rebuild is what broke it.
/// </summary>
public sealed class ClueManager : Component
{
public static ClueManager Instance { get; private set; }
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public static ClueManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Clue Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<ClueManager>();
}
readonly List<GameObject> _built = new();
/// <summary>How many are standing right now.</summary>
public int Built => _built.Count( g => g.IsValid() );
/// <summary>
/// A clue's image as typed, made one the engine can load: a BARE FILE NAME is looked for in the clue folder
/// (`materials/clues/`), because that is where clue art goes. A path is left as typed.
///
/// ⛔ A BARE NAME USED TO LOAD FROM THE PROJECT'S ROOT, AND FAIL. Typed as `basalt_hex_tiles.png` alone — the name the
/// file has in that folder — the engine looked for `/basalt_hex_tiles.png`, logged "Image.Load … not found", and the
/// clue drew nothing. The folder's README said to drop art there; nothing then found it by its name.
/// </summary>
public static string ResolveImage( string image )
{
if ( string.IsNullOrWhiteSpace( image ) ) return image;
var p = image.Trim().Replace( '\\', '/' );
if ( p.Contains( '/' ) || p.Contains( ':' ) ) return p;
var inFolder = $"{ToolSettings.ClueImageFolder}/{p}";
return FileSystem.Mounted.FileExists( inFolder ) ? inFolder : p;
}
/// <summary>Destroy what is standing and build the config again.</summary>
public void Rebuild()
{
foreach ( var g in _built ) g?.Destroy();
_built.Clear();
var list = ActiveConfig.Current?.Clues;
if ( list is null || list.Count == 0 ) return;
foreach ( var spot in list )
Build( spot );
Log.Info( $"[nz] {Built} of {list.Count} clue(s) built" );
}
void Build( ClueSpot spot )
{
var go = Scene.CreateObject();
go.Name = "Clue";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⚠️ NUDGED OFF THE SURFACE BY THE WALLBUY'S 1.5 UNITS. A panel drawn exactly in
// the wall plane z-fights with it, which reads as a flickering texture rather than
// as a placement that needs moving.
var n = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
go.WorldPosition = spot.Position + n * 1.5f;
go.WorldRotation = OnSurface( spot );
var wp = go.Components.Create<WorldPanel>();
// ⚠️ THE SPOT'S SIZE, FLAT. An auto-fit that computed this from glyph metrics was
// tried and removed: it was a guess, and the only way it could be wrong that anyone
// would see was by being too SMALL. A default board far larger than any clue needs
// costs nothing — there is no background, so spare room is invisible.
wp.PanelSize = spot.Size;
// ⛔ `LookAtCamera` OFF. A clue is writing ON a wall, not a billboard — turning to
// face the player would make it slide across the surface as you walk past, which is
// the one thing that would give away that it is a panel rather than paint.
wp.LookAtCamera = false;
var clue = go.Components.Create<Clue>();
clue.Spot = spot;
// ⚠️ AFTER the Clue, because `CluePanel` reads `Clue.Spot` on its first build. A
// panel created first would hash a null spot and draw an empty board until
// something else invalidated it.
go.Components.Create<CluePanel>();
var image = ResolveImage( spot.Image );
if ( !string.IsNullOrWhiteSpace( image ) )
{
// ⛔ AN ABSOLUTE PATH IS CAUGHT HERE, BECAUSE THE ENGINE'S REFUSAL IS CRYPTIC.
// Typing `c:/users/.../images.jpg` produces "The path `/c:/...` cannot contain the
// `:` character" — which reads as a parsing quirk rather than as the real rule:
// game code is sandboxed to the project and CANNOT see the rest of the disk. The
// file has to be copied in first, and nothing in that message says so.
if ( image.Contains( ':' ) || image.StartsWith( "/" ) || image.StartsWith( "\\" ) )
{
Log.Warning( $"[nz] clue image '{spot.Image}' is a path outside the project — the "
+ "game cannot read your drive. Copy the file into "
+ $"Assets/{ToolSettings.ClueImageFolder}/ and use a path like "
+ $"'{ToolSettings.ClueImageFolder}/yourimage.png'. Non-PNG usually needs "
+ "converting too." );
_built.Add( go );
return;
}
// ⚠️ CHECKED AND REPORTED, because the path is typed by hand. A missing texture
// draws nothing, and a clue showing nothing is indistinguishable from a clue
// whose text was left blank on purpose.
// ⚠️ NOT FOR BASALT'S LIVE CLUES — the hex map and the rings — which need no file: `LiveClues` draws them.
var tex = LiveClues.Is( image ) ? null : Texture.Load( image );
if ( tex is null && !LiveClues.Is( image ) )
Log.Warning( $"[nz] clue image '{spot.Image}'{( image != spot.Image ? $" (looked for '{image}')" : "" )}"
+ " not found — the panel will show only its text (nz_clue_list prints what each is set to)" );
}
_built.Add( go );
}
/// <summary>
/// How the panel sits on whatever it was placed against.
///
/// ⚠️ THE BUYABLE ENDING'S SPLIT, for the same reason: a clue belongs on a WALL more
/// often than on a floor, and the floor branch treats the normal as UP — which would
/// lay a wall-mounted panel flat in mid-air.
/// </summary>
static Rotation OnSurface( ClueSpot spot )
{
var n = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
if ( n.z <= 0.7f ) return Rotation.LookAt( n );
var heading = Rotation.FromYaw( spot.Yaw ).Forward;
var forward = (heading - n * heading.Dot( n )).Normal;
if ( forward.IsNearlyZero() )
return Rotation.From( 0f, spot.Yaw, 0f );
return Rotation.LookAt( forward, n );
}
}