Game/UI/GameCursor.razor
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@inherits PanelComponent
@namespace Sandbox

@*
	Chain Reaction's cursor: a lit fuse with a spark trailing behind it, and the single
	owner of the native pointer's visibility.

	Three rules, each one learned the hard way on an earlier s&box project and carried over
	unchanged because they are properties of the engine, not of that game:

	1. NEVER set Mouse.Visibility = Hidden. It reads like "hide the pointer" and it is not.
	   Hidden is documented as "the mouse is locked to the game and cannot interact with UI"
	   -- losing UI clicks is part of its definition, not a bug to work around. Visibility
	   stays Visible here and is touched nowhere else.

	2. Mouse.CursorType is the property that removes the pointer's IMAGE. It only governs
	   the case where no panel has claimed the cursor (empty space over the world), and it
	   has to be re-written every frame, because the UI writes it itself from whatever panel
	   is hovered. Panels are handled the other way, in CSS: every stylesheet in the project
	   declares cursor: none, so no panel can ever ask for the pointer back.

	3. Every element this file draws carries pointer-events: none, individually -- not just
	   the parents. These elements sit directly under the pointer by definition, so a single
	   hit-testable one swallows every click in the game before a button can see it.

	What is tailored to this game: the pointer is a fuse spark, it becomes the bomb you have
	selected when it crosses a board cell, and it punches on click. Its state is read off the
	deepest hovered panel, so no other file has to cooperate.
*@

<root class="cur-root">

	<div class="cur cur-@_state @PressClass" style="left:@(_x)px; top:@(_y)px;">

		@* The glyph. An arrowhead everywhere, the selected bomb over a board cell -- the
		   hotspot is the same corner either way, so the click still lands on the point. *@
		<label class="cur-tip">@TipGlyph</label>

		@* A hard point exactly on the hotspot, so precision clicking has something honest
		   to aim with even when the glyph behind it is a fat symbol. *@
		<div class="cur-dot"></div>

		@if ( _state == "bomb" )
		{
			<div class="cur-ring"></div>
			<div class="cur-ring ring-wide"></div>
		}
		else if ( _state == "ui" )
		{
			<div class="cur-ring"></div>
		}

		@if ( _pressed )
		{
			<div class="cur-burst"></div>
		}
	</div>

	@* The fuse: two embers lagging behind the pointer at different rates. Pure decoration,
	   drawn in their own absolutely positioned nodes so they never move the hotspot. *@
	<div class="cur-spark s1" style="left:@(_sx1)px; top:@(_sy1)px;"></div>
	<div class="cur-spark s2" style="left:@(_sx2)px; top:@(_sy2)px;"></div>

</root>

@code {
	/// <summary>
	/// Where the arrowhead's point sits relative to the panel's top-left, in pixels.
	/// Measured together with the glyph size in the stylesheet -- change one without the
	/// other and the point drifts off the real pointer, so every click lands slightly wrong.
	/// </summary>
	const float HotspotX = 4f;
	const float HotspotY = 3f;

	/// <summary>
	/// Hide the native pointer. Untick to get the OS cursor back while debugging -- input
	/// behaves identically either way, because this only changes what gets drawn.
	/// </summary>
	[Property] public bool HideNativeCursor { get; set; } = true;

	/// <summary>Stamped on the topmost panel of our own tree; the stylesheet hides the pointer from it down.</summary>
	const string HideClass = "no-native-cursor";

	Panel _rootPanel;

	int _x, _y;
	int _sx1, _sy1, _sx2, _sy2;

	// Trail positions in screen pixels, chased toward the mouse every frame.
	Vector2 _trail1, _trail2;
	bool _trailReady;

	bool _pressed;

	/// <summary>"default", "ui", "bomb" or "blocked" -- drives colour, glyph and rings.</summary>
	string _state = "default";

	string PressClass => _pressed ? "pressed" : "";

	ChainReactionGame Game => ChainReactionGame.Instance;

	/// <summary>
	/// The bomb the player is about to place, or None. Reactor mode has no hand -- a click
	/// there is a manual detonation -- so it falls through to the generic bomb glyph.
	/// </summary>
	GridManager.BombType SelectedBomb
	{
		get
		{
			var hand = Game?.Hand;
			if ( hand is null || !hand.IsValid() ) return GridManager.BombType.None;
			return hand.BombAt( hand.SelectedSlot );
		}
	}

	string TipGlyph => _state switch
	{
		"bomb"    => BombGlyph( SelectedBomb ),
		"blocked" => "✕",
		_         => "➤"
	};

	/// <summary>Same glyph vocabulary the bomb wheel and the grid preview use, so the cursor
	/// and the board never describe the same bomb two different ways.</summary>
	static string BombGlyph( GridManager.BombType t ) => t switch
	{
		GridManager.BombType.Cross    => "✛",
		GridManager.BombType.Sniper   => "↑",
		GridManager.BombType.Diagonal => "✕",
		GridManager.BombType.Square   => "■",
		GridManager.BombType.Chain    => "⛓",
		_                             => "💣"
	};

	protected override void OnDestroy()
	{
		// Leave the UI as we found it, including a pointer the player can actually see.
		_rootPanel?.RemoveClass( HideClass );
		Mouse.Visibility = MouseVisibility.Visible;
		Mouse.CursorType = "default";
	}

	protected override void OnUpdate()
	{
		ApplyMouseMode();
		ApplyCursorStyle();

		UpdatePosition();
		UpdateState();

		_pressed = Input.Down( "attack1" );

		StateHasChanged();
	}

	/// <summary>
	/// Re-asserts the mode as late in the frame as we can reach. OnUpdate alone left the
	/// pointer flashing on press and release: the UI handles those events and re-derives the
	/// cursor after every component's OnUpdate has run. OnPreRender happens after that, so
	/// this is the write that survives to be drawn.
	/// </summary>
	protected override void OnPreRender() => ApplyMouseMode();

	void ApplyMouseMode()
	{
		// Visible, always -- see rule 1 at the top of this file.
		Mouse.Visibility = MouseVisibility.Visible;

		if ( HideNativeCursor )
			Mouse.CursorType = "none";
	}

	/// <summary>
	/// Stamps the hide class on the topmost panel of our tree. cursor is an inherited CSS
	/// property, so one rule on the root covers this whole panel and the empty space over
	/// the world in one go.
	/// </summary>
	void ApplyCursorStyle()
	{
		if ( Panel is null ) return;

		if ( _rootPanel is null || _rootPanel.IsDeleting )
		{
			_rootPanel = Panel;
			while ( _rootPanel.Parent is not null )
				_rootPanel = _rootPanel.Parent;
		}

		_rootPanel.SetClass( HideClass, HideNativeCursor );
	}

	void UpdatePosition()
	{
		float scale = Panel?.ScaleFromScreen ?? 1f;
		var m = Mouse.Position;

		if ( !_trailReady )
		{
			_trail1 = m;
			_trail2 = m;
			_trailReady = true;
		}

		// Frame-rate independent chase. The second ember is slower, so the two of them
		// stretch into a fuse line when you sweep and collapse to a point when you stop.
		_trail1 = Vector2.Lerp( _trail1, m, 1f - MathF.Pow( 0.0008f, Time.Delta ) );
		_trail2 = Vector2.Lerp( _trail2, _trail1, 1f - MathF.Pow( 0.0040f, Time.Delta ) );

		// Ints, so no decimal separator can ever reach the inline style -- a comma from a
		// French locale would silently break the whole declaration.
		_x = (int)(m.x * scale - HotspotX);
		_y = (int)(m.y * scale - HotspotY);

		_sx1 = (int)(_trail1.x * scale - 3f);
		_sy1 = (int)(_trail1.y * scale - 3f);
		_sx2 = (int)(_trail2.x * scale - 2f);
		_sy2 = (int)(_trail2.y * scale - 2f);
	}

	/// <summary>
	/// The cursor says what a click would do, read off whatever panel is under it.
	///
	/// s&box sets :hover on the hovered panel and on its ancestors, so descending through
	/// HasHovered lands on the deepest one. Every panel component in the scene is searched
	/// because each ScreenPanel is its own root -- the HUD's tree is not reachable from ours.
	/// </summary>
	void UpdateState()
	{
		var hovered = FindHoveredPanel();

		if ( hovered is null )
		{
			_state = "default";
			return;
		}

		if ( IsBoardCell( hovered ) )
		{
			// Over the board: armed when there is a bomb to place, or in Reactor mode where
			// a click is always a detonation. Refused when the hand is empty.
			bool armed = Game?.Mode == GameMode.Idle || SelectedBomb != GridManager.BombType.None;
			_state = armed ? "bomb" : "blocked";
			return;
		}

		_state = IsClickable( hovered ) ? "ui" : "default";
	}

	Panel FindHoveredPanel()
	{
		foreach ( var pc in Scene.GetAllComponents<PanelComponent>() )
		{
			if ( pc == this ) continue;

			var p = pc.Panel;
			if ( p is null || !p.HasHovered ) continue;

			return Deepest( p );
		}

		return null;
	}

	static Panel Deepest( Panel p )
	{
		while ( true )
		{
			Panel next = null;

			foreach ( var c in p.Children )
			{
				if ( c.HasHovered ) { next = c; break; }
			}

			if ( next is null ) return p;
			p = next;
		}
	}

	/// <summary>
	/// Class-name matching rather than a registry of panels, so new UI is covered the day it
	/// is written. Both boards name their tiles "cell": the wave grid uses cell, the reactor
	/// uses idle-cell.
	/// </summary>
	static bool IsBoardCell( Panel p )
	{
		for ( var q = p; q is not null; q = q.Parent )
		{
			if ( q.HasClass( "cell" ) || q.HasClass( "idle-cell" ) ) return true;
		}

		return false;
	}

	/// <summary>
	/// Anything that takes a click. Nearly all of it is a real &lt;button&gt;, which the element
	/// check below catches for free; this list is only for the div-based buttons — the upgrade
	/// and perk rows in the reactor panel.
	///
	/// Kept deliberately short. Loose markers like "card" or "back" match the containers those
	/// buttons live in, and since the search walks ancestors, one loose entry lights the cursor
	/// up over half a panel.
	/// </summary>
	static readonly string[] ClickableMarkers = { "idle-up", "btn", "clickable" };

	static bool IsClickable( Panel p )
	{
		for ( var q = p; q is not null; q = q.Parent )
		{
			if ( q.ElementName == "button" ) return true;

			// Classes is the whole class list as one space-separated string, which is all
			// the matching below needs.
			var classes = q.Classes;
			if ( string.IsNullOrEmpty( classes ) ) continue;

			foreach ( var marker in ClickableMarkers )
			{
				if ( classes.Contains( marker, StringComparison.OrdinalIgnoreCase ) ) return true;
			}
		}

		return false;
	}
}