Component attached to a child GameObject named "sbh_cartel_grupo" that displays a combat group indicator above a bot. It shows a transient numeric label that pops, floats and fades after 5 seconds, and a persistent flame prefab tinted to the group's color while the bot remains grouped.
namespace SBH;
using Sandbox;
using System;
using System.Linq;
/// <summary>
/// [SBHBase] Señalización del grupo de combate (Faccion.Grupo) sobre la
/// cabeza de un nextbot, en dos capas:
/// - El NÚMERO: aparece con un pop al asignar el grupo, flota respirando y
/// se desvanece a los 5 segundos. Es el feedback del momento.
/// - El FUEGUITO: pelotita con llamas hacia arriba del color del grupo
/// (prefabs/efectos/fx_grupo.prefab teñido en runtime). Queda mientras
/// el bot siga agrupado — la marca permanente.
/// Vive en un GameObject hijo "sbh_cartel_grupo" que Faccion crea/destruye
/// al ciclar de grupo; el componente billboardea y anima.
/// </summary>
public sealed class CartelGrupo : Component
{
const string NombreCartel = "sbh_cartel_grupo";
const string RutaFuego = "prefabs/efectos/fx_grupo.prefab";
const float DuracionNumero = 5f;
const float EscalaBase = 0.75f; // tamaño del número (el knob principal)
const float AlturaNumero = 30f; // base del número sobre el fueguito
// Un color por grupo (índice 0 = sin grupo, no se usa)
static readonly Color[] Colores =
{
Color.White,
new Color( 1f, 0.30f, 0.25f ), // 1 rojo
new Color( 0.30f, 0.55f, 1f ), // 2 azul
new Color( 0.35f, 1f, 0.40f ), // 3 verde
new Color( 1f, 0.90f, 0.30f ), // 4 amarillo
};
public static Color ColorDe( int grupo )
=> Colores[grupo.Clamp( 0, Colores.Length - 1 )];
Color _color = Color.White;
GameObject _numero;
GameObject _fuego;
TextRenderer _texto;
TimeSince _desdeAsignado;
/// <summary>
/// Crea, actualiza o destruye el cartel del dueño según su grupo
/// (0 o menos = se va todo, número y fuego).
/// </summary>
public static void Actualizar( GameObject dueño, int grupo )
{
var hijo = dueño.Children.FirstOrDefault( c => c.Name == NombreCartel );
if ( grupo <= 0 )
{
hijo?.Destroy();
return;
}
if ( hijo == null )
{
hijo = new GameObject( true, NombreCartel );
hijo.Parent = dueño;
// En la coronilla: lo más alto del modelo, apenas por encima
var renderer = dueño.Components.Get<SkinnedModelRenderer>();
var alto = renderer.IsValid()
? renderer.Bounds.Maxs.z - dueño.WorldPosition.z + 4f
: 84f;
hijo.LocalPosition = Vector3.Up * alto;
hijo.Components.Create<CartelGrupo>();
}
hijo.Components.Get<CartelGrupo>()?.Mostrar( grupo );
}
void Mostrar( int grupo )
{
_color = ColorDe( grupo );
_desdeAsignado = 0f;
// El número: renace en cada asignación (aunque ya se hubiera ido)
if ( !_numero.IsValid() )
{
_numero = new GameObject( true, "numero" );
_numero.Parent = GameObject;
_texto = _numero.Components.Create<TextRenderer>();
_texto.Scale = EscalaBase;
}
var scope = _texto.TextScope;
scope.Text = grupo.ToString();
scope.TextColor = _color;
scope.FontSize = 96f;
scope.FontWeight = 800;
_texto.TextScope = scope;
// El fueguito: se clona una vez y de ahí en más solo cambia de color
if ( !_fuego.IsValid() )
{
var config = new CloneConfig { Transform = Transform.World, StartEnabled = true };
var prefab = ResourceLibrary.Get<PrefabFile>( RutaFuego );
_fuego = prefab != null ? GameObject.Clone( prefab, config ) : null;
if ( !_fuego.IsValid() )
_fuego = GameObject.Clone( RutaFuego, config );
if ( !_fuego.IsValid() )
{
Log.Warning( $"CartelGrupo: no pude cargar {RutaFuego} — el bot queda sin fueguito" );
}
else
{
_fuego.Enabled = true; // cinturón: algún camino de clonado ignora StartEnabled
_fuego.Parent = GameObject;
_fuego.LocalPosition = Vector3.Zero;
}
}
if ( _fuego.IsValid() )
{
// El Tint multiplica el blanco de las capas → llamas del color del grupo
foreach ( var fx in _fuego.Components
.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
fx.Tint = _color;
}
}
protected override void OnUpdate()
{
if ( !_numero.IsValid() ) return;
var t = (float)_desdeAsignado;
if ( t >= DuracionNumero )
{
// El número cumplió su ciclo; el fueguito sigue marcando el grupo
_numero.Destroy();
_numero = null;
_texto = null;
return;
}
// Pop de entrada (llega grande y se asienta) + respiración sutil
var asentado = (t / 0.3f).Clamp( 0f, 1f );
var factor = 1.6f - 0.6f * asentado + MathF.Sin( t * 4f ) * 0.04f;
_texto.Scale = EscalaBase * factor;
// Flota: sube despacio con un vaivén lateral suave
_numero.LocalPosition = Vector3.Up * (AlturaNumero + t * 3f)
+ Vector3.Left * MathF.Sin( t * 2.2f ) * 2f;
// Último segundo: se desvanece
var alpha = t > DuracionNumero - 1f ? (DuracionNumero - t).Clamp( 0f, 1f ) : 1f;
var scope = _texto.TextScope;
scope.TextColor = _color.WithAlpha( alpha );
_texto.TextScope = scope;
}
protected override void OnPreRender()
{
var cam = Scene.Camera;
if ( !cam.IsValid() ) return;
// De frente a la cámara (el fuego es LookAtCamera por sí solo, esto
// es por el número)
WorldRotation = Rotation.LookAt( WorldPosition - cam.WorldPosition );
}
}