Code/SpriteBillboard.cs
namespace SBH;

using Sandbox;
using System.Linq;

/// <summary>
/// [SBHBase] Billboard de los nextbots 2D (2026-08-17/18, pedido de Héctor:
/// "así funcionan en GMod"). En CADA cliente el sprite se ve de frente a la
/// cámara de ESE cliente; el GameObject no se toca (la IA, la red, la
/// cápsula y los hitboxes siguen con su rumbo real). Excepción: el bot que el
/// jugador local está POSEYENDO no billboardea para él (lo gira con el mouse)
/// — los demás lo siguen viendo de frente.
///
/// TERCER intento y el que funciona por diseño: los dos anteriores giraban
/// lo del renderer (bone override → plano de canto/culling; SceneModel.
/// Transform → el motor lo re-sincroniza con el GameObject después de
/// CUALQUIER gancho, medido con sbh.sprite.debug: yawLeido == go siempre).
/// Ahora el sprite lo dibuja un SceneModel PROPIO (mismo vmdl, mismo
/// material del flipbook, misma escala) cuya transform solo escribo yo, y
/// el del renderer se apaga con RenderingEnabled=false impuesto cada frame
/// (receta del Spartan Kick: NUNCA Renderer.Enabled, que recrea el modelo).
/// Lo cuelga solo SpriteInyector a todo bot con sprite2d.vmdl (no hace
/// falta tocar prefabs ni el Nextbot 2D Maker).
/// </summary>
public sealed class SpriteBillboard : Component
{
	[Property, Description( "Corrección de yaw si el quad mira para otro lado (0 = el frente del modelo es +X)" )]
	public float AjusteYaw { get; set; } = 0f;

	/// <summary>
	/// El bot que el jugador LOCAL posee: se lo excluye del billboard en esta
	/// máquina (lo setea el juego al poseer / soltar). Estático a propósito:
	/// la posesión es una sola por cliente.
	/// </summary>
	public static GameObject SinBillboard { get; set; }

	SkinnedModelRenderer _renderer;
	SceneModel _propio;          // el sprite que se ve: transform 100% nuestra
	Material _materialPuesto;

	// Radiografía (sbh.sprite.debug 1)
	int _aplicadas;
	float _yawPuesto;

	protected override void OnStart()
	{
		_renderer = Components.Get<SkinnedModelRenderer>( true );
	}

	protected override void OnDestroy()
	{
		Restaurar();
		_propio?.Delete();
		_propio = null;
	}

	protected override void OnDisabled()
	{
		Restaurar();
		if ( _propio.IsValid() ) _propio.RenderingEnabled = false;
	}

	/// <summary> ¿Corresponde billboardear en esta máquina ahora? </summary>
	bool Corresponde()
	{
		if ( !_renderer.IsValid() || _renderer.SceneModel == null || _renderer.Model == null ) return false;
		if ( SinBillboard.IsValid() && SinBillboard == GameObject.Root ) return false; // el poseído local
		if ( GameObject.Root.Tags.Has( "sbh_muerto" ) ) return false;                 // los muertos quedan como cayeron
		return true;
	}

	/// <summary> El renderer original vuelve a dibujarse (poseído / muerto / apagado). </summary>
	void Restaurar()
	{
		if ( !_renderer.IsValid() ) return;
		if ( _renderer.SceneModel != null ) _renderer.SceneModel.RenderingEnabled = true;
		if ( _renderer.Tint == Color.Transparent ) _renderer.Tint = Color.White;
	}

	/// <summary>
	/// Dibuja el sprite de frente a los ojos del jugador local (solo yaw) y
	/// esconde el del renderer. Corre en Stage.UpdateBones desde SpriteInyector.
	/// </summary>
	internal void Aplicar( Vector3 ojos )
	{
		if ( !Corresponde() )
		{
			Restaurar();
			if ( _propio.IsValid() ) _propio.RenderingEnabled = false;
			return;
		}

		var original = _renderer.SceneModel;

		if ( !_propio.IsValid() || _propio.Model != _renderer.Model )
		{
			_propio?.Delete();
			_propio = new SceneModel( Scene.SceneWorld, _renderer.Model, GameObject.WorldTransform );
			_propio.UseAnimGraph = false;
			_propio.CurrentSequence.Name = "idle"; // los clips del sprite2d son quietos: cualquiera posa el quad
			_materialPuesto = null;
		}

		// Un SceneModel esqueletado sin Update() se queda con los huesos
		// colapsados (invisible): hay que avanzarle el reloj cada frame
		_propio.Update( Time.Delta );

		// Mismo material que el flipbook le pone al renderer (idle/run/attack)
		var material = _renderer.MaterialOverride;
		if ( material != _materialPuesto )
		{
			if ( material != null ) _propio.SetMaterialOverride( material );
			else _propio.ClearMaterialOverride();
			_materialPuesto = material;
		}

		var pos = GameObject.WorldPosition;
		var dir = (ojos - pos).WithZ( 0 );
		var yaw = dir.LengthSquared < 1f ? GameObject.WorldRotation.Yaw() : Rotation.LookAt( dir.Normal ).Yaw() + AjusteYaw;

		_propio.Transform = new Transform( pos, Rotation.FromYaw( yaw ), GameObject.WorldScale );
		_propio.RenderingEnabled = true;

		// Esconder el original: RenderingEnabled solo no alcanzó (el motor lo
		// vuelve a prender después de este gancho — medido 2026-08-18), así
		// que además se lo vuelve TRANSPARENTE: con F_ALPHA_TEST el alfa 0 del
		// tint recorta todos los píxeles. Se restaura al soltar/morir.
		original.RenderingEnabled = false;
		if ( _renderer.Tint != Color.Transparent ) _renderer.Tint = Color.Transparent;

		_yawPuesto = yaw;
		_aplicadas++;
	}

	internal string Radiografia()
		=> $"{GameObject.Name}: aplicadas={_aplicadas} yawPuesto={_yawPuesto:F0} go={GameObject.WorldRotation.Yaw():F0} " +
		   $"propio={( _propio.IsValid() ? $"ok yaw={_propio.Transform.Rotation.Yaw():F0} render={_propio.RenderingEnabled} bounds={_propio.Bounds.Size:F0}" : "null" )} " +
		   $"tintOrig={( _renderer.IsValid() ? _renderer.Tint.a.ToString( "F1" ) : "?" )} " +
		   $"originalRender={( _renderer.IsValid() && _renderer.SceneModel != null ? _renderer.SceneModel.RenderingEnabled.ToString() : "?" )} " +
		   $"mat={( _materialPuesto?.ResourcePath ?? "-" )} corresponde={Corresponde()}";
}

/// <summary>
/// [SBHBase] (1) Inyecta SpriteBillboard a todo nextbot que use el modelo
/// compartido de sprites 2D (sprite2d.vmdl) — mismo patrón que
/// ExpresionesInyector; corre en cada cliente (el componente es local, no
/// viaja por red) y cubre a los bots 2D de terceros (mismo vmdl). (2) En
/// Stage.UpdateBones — lo último antes de dibujar — aplica el giro de todos
/// los billboards hacia los ojos del jugador local (AudioListener: viaja con
/// la posesión; Scene.Camera.WorldPosition se queda clavado al poseer).
/// </summary>
public sealed class SpriteInyector : GameObjectSystem
{
	TimeSince _desdeEscaneo;
	TimeSince _desdeLog;

	public SpriteInyector( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, 26, Escanear, "SpriteInyector" );
		Listen( Stage.UpdateBones, 90, AplicarTodos, "SpriteBillboard" );
	}

	public static bool EsSprite( SkinnedModelRenderer renderer )
		=> renderer.IsValid() && (renderer.Model?.ResourcePath ?? "").EndsWith( "/sprite2d.vmdl", System.StringComparison.OrdinalIgnoreCase );

	void Escanear()
	{
		if ( _desdeEscaneo < 1f ) return;
		_desdeEscaneo = 0f;

		foreach ( var renderer in Scene.GetAllComponents<SkinnedModelRenderer>() )
		{
			var go = renderer.GameObject;
			if ( !go.IsValid() || !go.Tags.Has( "sbh_nextbot" ) ) continue;
			if ( go.Tags.Has( "sbh_accesorio" ) ) continue;
			if ( !EsSprite( renderer ) ) continue;
			if ( go.Components.Get<SpriteBillboard>( true ) != null ) continue;

			go.Components.Create<SpriteBillboard>();
			if ( SbhSpriteConVars.Debug )
				Log.Info( $"🖼 SpriteBillboard inyectado a {go.Name} ({renderer.Model?.ResourcePath})" );
		}
	}

	void AplicarTodos()
	{
		if ( !BuscarOjos( out var ojos ) ) return;

		var n = 0;
		foreach ( var b in Scene.GetAllComponents<SpriteBillboard>() )
		{
			if ( !b.IsValid() ) continue;
			b.Aplicar( ojos );
			n++;
		}

		if ( SbhSpriteConVars.Debug && _desdeLog > 1f )
		{
			_desdeLog = 0f;
			Log.Info( $"🖼 billboards={n} ojos={ojos:F0}" );
			foreach ( var b in Scene.GetAllComponents<SpriteBillboard>().Take( 3 ) )
				Log.Info( "   " + b.Radiografia() );
		}
	}

	bool BuscarOjos( out Vector3 pos )
	{
		var oido = Scene.GetAllComponents<AudioListener>().FirstOrDefault( a => a.IsValid() && a.Active );
		if ( oido.IsValid() )
		{
			pos = oido.WorldPosition;
			return true;
		}

		if ( Scene.Camera.IsValid() )
		{
			pos = Scene.Camera.WorldPosition;
			return true;
		}

		pos = default;
		return false;
	}
}

/// <summary> ConVar de diagnóstico del billboard 2D: sbh.sprite.debug 1 </summary>
public static class SbhSpriteConVars
{
	[ConVar( "sbh.sprite.debug", Help = "Radiografía del billboard de los nextbots 2D (1 línea/s)" )]
	public static bool Debug { get; set; }
}