A GameObjectSystem that scans the scene for nextbot sprunki models and injects a RitmoPersonaje component with configured SoundEvent loops based on the model vmdl name. It keeps a catalog of character-to-loop mappings, creates a BaileProcedural for clukr, and warns once for unknown sprunki models.
namespace SBH;
using Sandbox;
using System.Collections.Generic;
/// <summary>
/// [SBHBase] Inyecta el RitmoPersonaje configurado a todo nextbot sprunki
/// que aparezca en escena (tag "sbh_nextbot"), identificando al personaje
/// por el nombre de su vmdl. Mismo patrón que SbhPuente: las integraciones
/// se inyectan en runtime y los prefabs de la librería base quedan limpios
/// — SBHBase ni se entera de que existe el sistema de ritmos.
/// El catálogo cubre los 20 personajes canónicos del juego original (ver
/// MAPA_PERSONAJES.md en Work S&BOX\Pack-01-Sprunkis\00-audios-ritmos).
/// </summary>
public sealed class RitmoInyector : GameObjectSystem
{
sealed record Loops( string NormalA, string NormalB, string HorrorA, string HorrorB );
// clave = fragmento del nombre del vmdl · rutas relativas a sounds/ritmos/
static readonly Dictionary<string, Loops> Catalogo = new()
{
// Los tres ya modelados (con sus alias actuales orng/grey/red)
["sprunki_oren"] = new( "orange_oren/kick", null, "orange_oren/kick3", null ),
["sprunki_orng"] = new( "orange_oren/kick", null, "orange_oren/kick3", null ),
["sprunki_raddy"] = new( "red_raddy/snare", null, "red_raddy/snare3", null ),
["sprunki_red"] = new( "red_raddy/snare", null, "red_raddy/snare3", null ),
["sprunki_gray"] = new( "gray_gray/bass1", "gray_gray/bass2", "gray_gray/bass3", "gray_gray/bass4" ),
["sprunki_grey"] = new( "gray_gray/bass1", "gray_gray/bass2", "gray_gray/bass3", "gray_gray/bass4" ),
// El resto del elenco canónico, listos para cuando existan los modelos
["sprunki_clukr"] = new( "silver_clukr/ride_cymbal", "silver_clukr/ride_cymbal2", "silver_clukr/knock_knocn", "silver_clukr/knock_knocn2" ),
["sprunki_funbot"] = new( "fun_bot/amen", null, "fun_bot/amen3", null ),
["sprunki_vineria"] = new( "green_vineria/shaky_shaky", null, "green_vineria/shakey", null ),
["sprunki_brud"] = new( "brown_brud/goofy_ahh_thing", null, "brown_brud/weird_sound", "brown_brud/weird_sound2" ),
["sprunki_garnold"] = new( "gold_garnold/arpeg", null, "gold_garnold/slow_arpeg", null ),
["sprunki_owakcx"] = new( "lime_owakcx/wind_up1", "lime_owakcx/wind_up2", "lime_owakcx/better_windup", null ),
["sprunki_sky"] = new( "sky_blue_sky/music_box1", "sky_blue_sky/music_box2", "sky_blue_sky/music1", "sky_blue_sky/music2" ),
["sprunki_mrsun"] = new( "mr_sun/piano1", "mr_sun/piano2", "mr_sun/piano_new1", "mr_sun/piano_new2" ),
["sprunki_durple"] = new( "purple_durple/tromp_new1", "purple_durple/tromp_new2", "purple_durple/horns1", "purple_durple/horns2" ),
["sprunki_mrtree"] = new( "mr_tree/organ1", "mr_tree/organ2", "mr_tree/slow_organ1", "mr_tree/slow_organ2" ),
["sprunki_simon"] = new( "yellow_simon/square1", "yellow_simon/square2", "yellow_simon/square_new1", "yellow_simon/square_new2" ),
["sprunki_tunner"] = new( "tan_tunner/whistle1", "tan_tunner/whistle2", "tan_tunner/whistle3", "tan_tunner/whistle4" ),
["sprunki_mrfuncomputer"] = new( "mr_fun_computer/aising1", "mr_fun_computer/aising2", "mr_fun_computer/aispeak1", "mr_fun_computer/aispeak2" ),
["sprunki_wenda"] = new( "white_wenda/boring_hey_sound", null, "white_wenda/crazy", null ),
["sprunki_pinki"] = new( "pink_pinki/femchoir1", "pink_pinki/femchoir2", "pink_pinki/choir1", "pink_pinki/choir2" ),
["sprunki_jevin"] = new( "blue_jevin/new_choir1", "blue_jevin/new_choir2", "blue_jevin/male_choir", "blue_jevin/male_choir2" ),
// El secreto: mudo en fase normal, solo existe en horror
["sprunki_black"] = new( null, null, "blackx/creepy1", "blackx/creepy2" ),
// El Basic (polo en blanco, sin personaje): MUDO por diseño en ambas
// fases — la entrada existe para declararlo y que el inyector no avise
["sprunki_base"] = new( null, null, null, null ),
["sprunki_hrr_base"] = new( null, null, null, null ),
// ---- Personajes HORROR (sprunki_hrr_*): personajes COMPLETOS aparte,
// su loop "normal" ES el sonido horror — cantan horror en toda fase
// (como Black en el original). El prefijo hrr evita colisiones con el
// Contains de los nombres base.
["sprunki_hrr_oren"] = new( "orange_oren/kick3", null, "orange_oren/kick3", null ),
["sprunki_hrr_raddy"] = new( "red_raddy/snare3", null, "red_raddy/snare3", null ),
["sprunki_hrr_gray"] = new( "gray_gray/bass3", "gray_gray/bass4", "gray_gray/bass3", "gray_gray/bass4" ),
["sprunki_hrr_clukr"] = new( "silver_clukr/knock_knocn", "silver_clukr/knock_knocn2", "silver_clukr/knock_knocn", "silver_clukr/knock_knocn2" ),
["sprunki_hrr_funbot"] = new( "fun_bot/amen3", null, "fun_bot/amen3", null ),
["sprunki_hrr_vineria"] = new( "green_vineria/shakey", null, "green_vineria/shakey", null ),
["sprunki_hrr_brud"] = new( "brown_brud/weird_sound", "brown_brud/weird_sound2", "brown_brud/weird_sound", "brown_brud/weird_sound2" ),
["sprunki_hrr_garnold"] = new( "gold_garnold/slow_arpeg", null, "gold_garnold/slow_arpeg", null ),
["sprunki_hrr_owakcx"] = new( "lime_owakcx/better_windup", null, "lime_owakcx/better_windup", null ),
["sprunki_hrr_sky"] = new( "sky_blue_sky/music1", "sky_blue_sky/music2", "sky_blue_sky/music1", "sky_blue_sky/music2" ),
["sprunki_hrr_mrsun"] = new( "mr_sun/piano_new1", "mr_sun/piano_new2", "mr_sun/piano_new1", "mr_sun/piano_new2" ),
["sprunki_hrr_durple"] = new( "purple_durple/horns1", "purple_durple/horns2", "purple_durple/horns1", "purple_durple/horns2" ),
["sprunki_hrr_mrtree"] = new( "mr_tree/slow_organ1", "mr_tree/slow_organ2", "mr_tree/slow_organ1", "mr_tree/slow_organ2" ),
["sprunki_hrr_simon"] = new( "yellow_simon/square_new1", "yellow_simon/square_new2", "yellow_simon/square_new1", "yellow_simon/square_new2" ),
["sprunki_hrr_tunner"] = new( "tan_tunner/whistle3", "tan_tunner/whistle4", "tan_tunner/whistle3", "tan_tunner/whistle4" ),
["sprunki_hrr_mrfuncomputer"] = new( "mr_fun_computer/aispeak1", "mr_fun_computer/aispeak2", "mr_fun_computer/aispeak1", "mr_fun_computer/aispeak2" ),
["sprunki_hrr_wenda"] = new( "white_wenda/crazy", null, "white_wenda/crazy", null ),
["sprunki_hrr_pinki"] = new( "pink_pinki/choir1", "pink_pinki/choir2", "pink_pinki/choir1", "pink_pinki/choir2" ),
["sprunki_hrr_jevin"] = new( "blue_jevin/male_choir", "blue_jevin/male_choir2", "blue_jevin/male_choir", "blue_jevin/male_choir2" ),
["sprunki_hrr_black"] = new( "blackx/creepy1", "blackx/creepy2", "blackx/creepy1", "blackx/creepy2" ),
};
TimeSince _desdeEscaneo;
readonly HashSet<string> _sinCatalogo = new(); // para avisar una sola vez por modelo
public RitmoInyector( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, 20, Escanear, "RitmoInyectorSprunki" );
}
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;
// Accesorios bonemerge (Easy Bonemerge de SBHBase): heredan el tag
// del bot que visten, pero son decoración — no cantan
if ( go.Tags.Has( "sbh_accesorio" ) ) continue;
if ( go.Components.Get<RitmoPersonaje>( true ) != null ) continue;
var modelo = renderer.Model?.ResourcePath ?? "";
if ( modelo == "" ) continue;
var enganchado = false;
foreach ( var (clave, loops) in Catalogo )
{
if ( !modelo.Contains( clave ) ) continue;
enganchado = true;
var ritmo = go.Components.Create<RitmoPersonaje>();
ritmo.LoopNormalA = Cargar( loops.NormalA );
ritmo.LoopNormalB = Cargar( loops.NormalB );
ritmo.LoopHorrorA = Cargar( loops.HorrorA );
ritmo.LoopHorrorB = Cargar( loops.HorrorB );
// El Clukr (base y horror) trae su bailecito procedural: la tapa
// rebota y los ojos giran mientras suena su loop
if ( clave.Contains( "clukr" ) && go.Components.Get<BaileProcedural>( true ) == null )
go.Components.Create<BaileProcedural>();
Log.Info( $"🎵 RitmoPersonaje inyectado a {go.Name} ({clave})" );
break;
}
// Nextbot sprunki sin ritmo en el catálogo: avisar una sola vez —
// casi siempre es un nombre de vmdl que no respeta el sprunki_<personaje>.
// Bots de OTROS packs (trevorhenderson, bfdi...) no son asunto de
// este inyector: silencio total (multi-pack desde 2026-07-26)
if ( !enganchado && modelo.Contains( "sprunki" ) && _sinCatalogo.Add( modelo ) )
Log.Warning( $"RitmoInyector: '{modelo}' no matchea ningún personaje del catálogo (el vmdl debe llamarse sprunki_<nombre canónico>, ver MAPA_PERSONAJES.md)" );
}
}
static SoundEvent Cargar( string ruta )
{
if ( string.IsNullOrEmpty( ruta ) ) return null;
var sonido = ResourceLibrary.Get<SoundEvent>( $"sounds/ritmos/{ruta}.sound" );
if ( sonido == null )
Log.Warning( $"RitmoInyector: no existe sounds/ritmos/{ruta}.sound" );
return sonido;
}
}