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; }
}