Component that controls facial expressions for a nextbot model using bodygroups. It handles blinking, attack face during attack animations, singing/mouth movement when PanicSound is active or when driven by rhythm, random idle expressions from short scripted sequences, and sets eyes closed on death. It parses small string sequences and drives SkinnedModelRenderer bodygroups accordingly.
namespace SBH;
using Sandbox;
using System.Collections.Generic;
/// <summary>
/// [SBHBase] Vida facial del nextbot vía bodygroups, replicando el sistema
/// de los nextbots DRGBase de GMod (SetBodygroup por eventos + timers):
/// - Parpadeo: cada tanto cierra los párpados un instante (grupo "eyelids").
/// - Cara de ataque: al lanzar el golpe pone la cara agresiva mientras dura
/// la anim (el SmileState/AngryState/NormalState del QC, vía AlAtacar).
/// - Canto: mientras el PanicSound suena (jugador cerca), la boca cambia
/// de cara al ritmo, como cuando cantan en el juego original.
/// - Miradas: sin cantar, de vez en cuando una expresión suelta.
/// - Muerte: ojos cerrados y listo.
/// Convención de colecciones en Blender (addon 2.13+): "face", "face_1"...
/// para caras y "eyelids_*" para párpados; sufijo -b = opción vacía.
/// Si el modelo no tiene bodygroups, el componente se apaga solo.
/// </summary>
public sealed class ExpresionesNextbot : Component
{
// ---------- Párpados ----------
[Property, Description( "Nombre del bodygroup de párpados" )]
public string GrupoParpados { get; set; } = "eyelids";
[Property, Description( "Índice de la opción 'ojos cerrados' del grupo (el addon lo detecta por nombre al exportar)" )]
public int IndiceParpadeo { get; set; } = 1;
[Property, Description( "Segundos entre pestañeos (±1 aleatorio)" )]
public float IntervaloParpadeo { get; set; } = 4f;
[Property, Description( "Cuánto dura el pestañeo" )]
public float DuracionParpadeo { get; set; } = 0.15f;
// ---------- Caras ----------
[Property, Description( "Nombre del bodygroup de caras" )]
public string GrupoCaras { get; set; } = "face";
[Property, Description( "Cantidad de caras del grupo (para elegir al azar cantando/mirando)" )]
public int CantidadCaras { get; set; } = 3;
[Property, Description( "Índice de la cara de ataque (-1 = sin cara de ataque)" )]
public int IndiceCaraAtaque { get; set; } = -1;
[Property, Description( "Cara mientras canta el ritmo (índice del grupo de caras; -1 = sin cara de canto)" )]
public int CaraCanto { get; set; } = 1;
[Property, Description( "Cuánto sostener la cara de canto por cada pulso del ritmo (0 = el pulso entero, 4.8s). Bajalo para personajes de sonidos entrecortados" )]
public float DuracionCanto { get; set; } = 0f;
[Property, Description( "Segundos entre aleteos de boca al cantar (cada personaje tiene su tempo: bajalo para bocas rápidas)" )]
public float RitmoCanto { get; set; } = 0.35f;
[Property, Description( "Intensidad mínima del PanicSound para considerar que canta (0-1)" )]
public float UmbralCanto { get; set; } = 0.05f;
[Property, Description( "Sin cantar: segundos entre expresiones sueltas (±1 aleatorio)" )]
public float IntervaloExpresion { get; set; } = 6f;
// ---------- Repertorio (el patrón del juego original) ----------
[Property, Description( "Mini-secuencias de expresión: cada string es una secuencia de pasos 'grupo:índice:segundos' separados por coma (ej: 'eyelids:1:0.06,eyelids:0:0.08,eyelids:1:0.06' = parpadeo doble). Al vencer el intervalo se elige UNA al azar y al terminar todo vuelve a 0 — el sistema de los sprites Polo del Sprunki original" )]
public List<string> Secuencias { get; set; } = new()
{
"eyelids:1:0.5", // ojos cerrados un momento
"eyelids:1:0.06,eyelids:0:0.08,eyelids:1:0.06", // parpadeo doble
"face:1:0.9", // gesto corto
"face:2:0.5,eyelids:1:0.1", // gesto + parpadeo
"face:1:0.3,face:2:0.3,face:1:0.3", // tic de caras
};
// ---------- Estado interno ----------
SkinnedModelRenderer _renderer;
PanicSound _panico;
NextbotBrain _cerebro;
Vida _vida;
bool _parpadeando;
TimeUntil _parpadeoEn;
TimeUntil _parpadeoFin;
bool _expresando;
bool _cantaba;
TimeUntil _expresionEn;
TimeUntil _bocaCambiaEn;
bool _atacando;
TimeUntil _ataqueFin;
// Secuencia en curso
List<(string Grupo, int Indice, float Segundos)> _pasos;
int _paso;
TimeUntil _pasoFin;
readonly HashSet<string> _gruposTocados = new();
// Canto por ritmo (lo alimenta el puente del juego desde RitmoPersonaje)
TimeUntil _cantoHasta;
bool _cantandoRitmo;
bool _bocaAbierta;
/// <summary>
/// Cara dictada desde afuera (la partitura de PartituraCaras, vía el puente
/// del juego): mientras sea >= 0 manda sobre canto, secuencias y parpadeo de
/// caras — solo la cara de ataque le gana. -1 = sin dictado, vida normal.
/// El driver la setea cada frame; no es una Property del inspector.
/// </summary>
public int CaraExterna { get; set; } = -1;
bool _caraExternaActiva;
int _caraExternaAplicada = -1;
// Reactivación al cambiar de modelo (fusión de carteles)
Model _modeloVisto;
bool _activo;
protected override void OnStart()
{
_renderer = Components.Get<SkinnedModelRenderer>();
_panico = Components.Get<PanicSound>();
RevisarModelo();
}
/// <summary>
/// El modelo puede cambiar en vivo (fusión de carteles): re-evaluar si
/// tiene bodygroups y arrancar/apagar el sistema según corresponda.
/// El componente NO se auto-deshabilita — queda latente por si el bot
/// se fusiona a un personaje que sí tenga.
/// </summary>
void RevisarModelo()
{
if ( !_renderer.IsValid() || _renderer.Model == _modeloVisto ) return;
_modeloVisto = _renderer.Model;
// Estado limpio al cambiar de cuerpo
if ( _expresando ) TerminarSecuencia();
_parpadeando = false;
_cantandoRitmo = false;
_cantaba = false;
_caraExternaActiva = false;
_caraExternaAplicada = -1;
_activo = _renderer.HasBodyGroups;
if ( !_activo ) return;
// Arrancar en la cara/párpados por defecto (normaliza el bitfield
// del prefab/modelo nuevo, que viene con todos los bits en 1)
_renderer.SetBodyGroup( GrupoCaras, 0 );
_renderer.SetBodyGroup( GrupoParpados, 0 );
_parpadeoEn = Game.Random.Float( IntervaloParpadeo - 1f, IntervaloParpadeo + 1f );
_expresionEn = Game.Random.Float( IntervaloExpresion - 1f, IntervaloExpresion + 1f );
}
/// <summary>
/// "Estoy cantando estos segundos": lo llama el puente del juego en cada
/// pulso de RitmoPersonaje. Sostiene la cara de canto — DuracionCanto
/// (si es > 0) recorta cuánto de cada pulso se sostiene, la perilla para
/// personajes de sonidos entrecortados.
/// </summary>
public void Cantar( float segundos )
{
if ( DuracionCanto > 0f )
segundos = System.MathF.Min( segundos, DuracionCanto );
if ( segundos > _cantoHasta )
_cantoHasta = segundos;
}
protected override void OnEnabled()
{
_cerebro = Components.Get<NextbotBrain>( true );
_vida = Components.Get<Vida>();
if ( _cerebro.IsValid() )
_cerebro.AlAtacar += AlAtacar;
if ( _vida.IsValid() )
_vida.AlMorir += AlMorir;
}
protected override void OnDisabled()
{
if ( _cerebro.IsValid() )
_cerebro.AlAtacar -= AlAtacar;
if ( _vida.IsValid() )
_vida.AlMorir -= AlMorir;
}
protected override void OnUpdate()
{
if ( !_renderer.IsValid() ) return;
RevisarModelo();
if ( !_activo ) return;
Parpadear();
// La cara de ataque manda mientras dura la anim; después, la vida normal
if ( _atacando )
{
if ( !_ataqueFin ) return;
_atacando = false;
_renderer.SetBodyGroup( GrupoCaras, 0 );
_caraExternaAplicada = -1; // que la partitura re-aplique su cara
}
// Partitura externa: un driver de afuera (PartituraCaras vía el puente
// del juego) dicta la cara casillero a casillero — manda sobre canto y
// secuencias. Al soltar (vuelve a -1) la vida normal sigue su curso.
if ( CaraExterna >= 0 )
{
if ( _expresando ) TerminarSecuencia();
_cantandoRitmo = false;
_cantaba = false;
if ( !_caraExternaActiva || CaraExterna != _caraExternaAplicada )
{
_renderer.SetBodyGroup( GrupoCaras, CaraExterna );
_caraExternaAplicada = CaraExterna;
_caraExternaActiva = true;
}
return;
}
if ( _caraExternaActiva )
{
_caraExternaActiva = false;
_caraExternaAplicada = -1;
_renderer.SetBodyGroup( GrupoCaras, 0 );
}
// Canto por ritmo: mientras la ventana no venció, la boca BATE —
// alterna cara de canto / reposo cada RitmoCanto segundos (como el
// original: un sonido de 4s son varios aleteos, no cara pegada)
if ( CaraCanto >= 0 && !_cantoHasta )
{
if ( !_cantandoRitmo )
{
if ( _expresando ) TerminarSecuencia();
_cantandoRitmo = true;
_bocaAbierta = false;
_bocaCambiaEn = 0f;
}
if ( _bocaCambiaEn )
{
_bocaAbierta = !_bocaAbierta;
_renderer.SetBodyGroup( GrupoCaras, _bocaAbierta ? CaraCanto : 0 );
_bocaCambiaEn = RitmoCanto;
}
return;
}
if ( _cantandoRitmo )
{
_cantandoRitmo = false;
_bocaAbierta = false;
_renderer.SetBodyGroup( GrupoCaras, 0 );
}
CantarPanico();
}
/// <summary> La cara agresiva durante toda la anim de ataque (AlAtacar del cerebro). </summary>
void AlAtacar()
{
if ( IndiceCaraAtaque < 0 ) return;
if ( _expresando ) TerminarSecuencia(); // la cara de ataque manda
_renderer?.SetBodyGroup( GrupoCaras, IndiceCaraAtaque );
_atacando = true;
_ataqueFin = _cerebro.IsValid() ? _cerebro.DuracionAnimAtaque : 0.6f;
}
/// <summary> Muerto: ojos cerrados para siempre (el cadáver queda en paz). </summary>
void AlMorir()
{
_renderer?.SetBodyGroup( GrupoParpados, IndiceParpadeo );
_renderer?.SetBodyGroup( GrupoCaras, 0 );
Enabled = false;
}
void Parpadear()
{
// Una secuencia en curso es dueña de los párpados: no pisarla
if ( _expresando ) return;
if ( !_parpadeando && _parpadeoEn )
{
_renderer.SetBodyGroup( GrupoParpados, IndiceParpadeo );
_parpadeando = true;
_parpadeoFin = DuracionParpadeo;
}
else if ( _parpadeando && _parpadeoFin )
{
_renderer.SetBodyGroup( GrupoParpados, 0 );
_parpadeando = false;
_parpadeoEn = Game.Random.Float( IntervaloParpadeo - 1f, IntervaloParpadeo + 1f );
}
}
void CantarPanico()
{
var cantando = _panico.IsValid() && _panico.Enabled && _panico.Intensidad > UmbralCanto;
if ( cantando )
{
if ( _expresando ) TerminarSecuencia(); // el canto manda
_cantaba = true;
// Boca al ritmo: cara aleatoria cada RitmoCanto (con un poco de swing)
if ( _bocaCambiaEn )
{
_renderer.SetBodyGroup( GrupoCaras, Game.Random.Int( 0, CantidadCaras - 1 ) );
_bocaCambiaEn = RitmoCanto * Game.Random.Float( 0.7f, 1.3f );
}
return;
}
// Acaba de dejar de cantar: boca a la cara normal
if ( _cantaba )
{
_cantaba = false;
_renderer.SetBodyGroup( GrupoCaras, 0 );
_expresionEn = Game.Random.Float( IntervaloExpresion - 1f, IntervaloExpresion + 1f );
}
// Repertorio: al vencer el intervalo, UNA mini-secuencia al azar
// (el "eye animation" de los sprites Polo del juego original)
if ( !_expresando && _expresionEn )
ArrancarSecuencia();
else if ( _expresando && _pasoFin )
AvanzarPaso();
}
// ---------- Reproductor de secuencias ----------
void ArrancarSecuencia()
{
if ( Secuencias == null || Secuencias.Count == 0 ) return;
_pasos = Parsear( Secuencias[Game.Random.Int( 0, Secuencias.Count - 1 )] );
if ( _pasos.Count == 0 ) return;
_paso = -1;
_expresando = true;
AvanzarPaso();
}
void AvanzarPaso()
{
_paso++;
if ( _paso >= _pasos.Count )
{
TerminarSecuencia();
return;
}
var (grupo, indice, segundos) = _pasos[_paso];
// Cara fuera de rango para este personaje: saltear el paso
if ( grupo == GrupoCaras && indice >= CantidadCaras )
{
AvanzarPaso();
return;
}
_renderer.SetBodyGroup( grupo, indice );
_gruposTocados.Add( grupo );
_pasoFin = segundos;
}
void TerminarSecuencia()
{
foreach ( var grupo in _gruposTocados )
_renderer?.SetBodyGroup( grupo, 0 );
_gruposTocados.Clear();
_expresando = false;
_expresionEn = Game.Random.Float( IntervaloExpresion - 1f, IntervaloExpresion + 1f );
}
static List<(string, int, float)> Parsear( string secuencia )
{
var pasos = new List<(string, int, float)>();
foreach ( var trozo in secuencia.Split( ',' ) )
{
var partes = trozo.Trim().Split( ':' );
if ( partes.Length != 3 ) continue;
if ( !int.TryParse( partes[1], out var indice ) ) continue;
if ( !float.TryParse( partes[2], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var segundos ) ) continue;
pasos.Add( (partes[0].Trim(), indice, segundos.Clamp( 0.02f, 5f )) );
}
return pasos;
}
}