RitmoInyector.cs

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.

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