EasterEgg/ClueManager.cs

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.

File Access
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 );
	}
}