Code/CartelGrupo.cs

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.

File AccessExternal Download
🌐 prefabs/efectos/fx_grupo.prefab
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 );
	}
}