Game/3D/GridCellPanel.cs

Component attached to a grid cell GameObject that manages the cell UI state, animations and floating text. It exposes methods to set the cell as empty, bomb or chest, queues and draws floating text each frame via the Camera.Hud, and runs simple scale tweens for visual feedback.

Native Interop
using Sandbox;
using Sandbox.UI;
using System;
using System.Threading.Tasks;

/// <summary>
/// Attach to each cell prefab GameObject alongside a WorldPanel + CellPanel (PanelComponent).
/// GridRenderer calls SetBomb / SetChest / SetEmpty to update display state.
/// Float text uses the Camera.Hud draw API — no WorldPanel spawning needed.
/// </summary>
public sealed class GridCellPanel : Component
{
	// ── Inspector ─────────────────────────────────────────────────────────
	/// Wire to the PanelComponent (CellPanel.razor) on this same GameObject
	[Property] public PanelComponent CellUI { get; set; }

	// ── Click callback (set by GridRenderer) ──────────────────────────────
	public Action OnClicked;

	// ── Display state (read by CellPanel.razor) ───────────────────────────
	public string Icon { get; private set; } = "";
	public string Label { get; private set; } = "";
	public int HitsLeft { get; private set; }
	public bool IsBomb { get; private set; }
	public bool IsChest { get; private set; }
	public bool Danger { get; private set; }

	// ── Float text queue (drawn each frame via OnUpdate) ──────────────────
	record FloatEntry( string Text, Color Col, float SpawnTime, float Duration );
	readonly System.Collections.Generic.List<FloatEntry> _floats = new();

	// ── API ───────────────────────────────────────────────────────────────
	public void SetEmpty()
	{
		Icon = ""; Label = ""; IsBomb = false; IsChest = false; Danger = false;
		Refresh();
	}

	public void SetBomb( string icon, string label )
	{
		Icon = icon; Label = label; IsBomb = true; IsChest = false;
		Refresh();
	}

	public void SetChest( string icon, int hitsLeft, bool danger )
	{
		Icon = icon; HitsLeft = hitsLeft; IsBomb = false; IsChest = true; Danger = danger;
		Refresh();
	}

	// ── Animations ────────────────────────────────────────────────────────
	public async Task AnimateFire()
	{
		await ScaleTo( 1.45f, 0.07f );
		await ScaleTo( 0.75f, 0.07f );
		await ScaleTo( 1.15f, 0.07f );
		await ScaleTo( 1.00f, 0.07f );
	}

	public async Task AnimateBurst( int coins, float mult )
	{
		await AnimateFire();

		Color col = mult >= 3f ? new Color( 0.50f, 0.47f, 0.87f )   // #7F77DD
				  : mult >= 2f ? new Color( 0.11f, 0.62f, 0.46f )   // #1D9E75
				  : new Color( 0.72f, 0.53f, 0.04f );   // #B8860B

		QueueFloat( $"+{coins}🪙", col, 0.9f );

		if ( mult >= 2f )
		{
			string label = mult >= 4f ? "×4 CHAIN REACTION! 💥"
						 : mult >= 3f ? "×3 ON FIRE! 🔥"
						 : "×2 CHAIN!";
			QueueFloat( label, new Color( 0.33f, 0.29f, 0.72f ), 1.3f );
		}
	}

	void QueueFloat( string text, Color col, float duration )
	{
		_floats.Add( new FloatEntry( text, col, Time.Now, duration ) );
	}

	// ── Draw floating text via Hud each frame ─────────────────────────────
	protected override void OnUpdate()
	{
		if ( _floats.Count == 0 ) return;
		var cam = Scene.Camera;
		if ( cam is null ) return;

		_floats.RemoveAll( f => Time.Now - f.SpawnTime >= f.Duration );

		foreach ( var f in _floats )
		{
			float t = (Time.Now - f.SpawnTime) / f.Duration;
			float alpha = 1f - t;
			var worldPos = WorldPosition + Vector3.Up * (40f + t * 60f);

			// Cull if behind camera
			var camFwd = cam.WorldRotation.Forward;
			if ( Vector3.Dot( camFwd, (worldPos - cam.WorldPosition).Normal ) < 0.1f )
				continue;

			var screenPos = cam.PointToScreenPixels( worldPos );

			cam.Hud.DrawText(
				new TextRendering.Scope(
					f.Text,
					f.Col.WithAlpha( alpha ),
					20,
					weight: 700
				),
				screenPos
			);
		}
	}

	// ── Scale tween ───────────────────────────────────────────────────────
	async Task ScaleTo( float target, float duration )
	{
		float elapsed = 0f;
		float start = GameObject.LocalScale.x;

		while ( elapsed < duration )
		{
			elapsed += Time.Delta;
			float t = MathF.Min( elapsed / duration, 1f );
			float s = start + (target - start) * t;
			GameObject.LocalScale = new Vector3( s, s, s );
			await Task.Yield();
		}

		GameObject.LocalScale = new Vector3( target, target, target );
	}

	// ── Refresh ───────────────────────────────────────────────────────────
	void Refresh() => CellUI?.StateHasChanged();

	// ── Init ──────────────────────────────────────────────────────────────
	protected override void OnStart()
	{
		if ( CellUI?.Panel is not null )
			CellUI.Panel.AddEventListener( "onclick", _ => OnClicked?.Invoke() );
	}
}