🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (73 total matches found)
ksbravo.sbhbase / Code/HuesosManipulados.cs
Game library
namespace SBH;

using Sandbox;
using System.Collections.Generic;

/// <summary>
/// [SBHBase] Huesos manipulados a mano (los ManipulateBone* de GMod, para el
/// Advanced Bone Tool): guarda un ajuste por hueso (ángulos/posición/escala,
/// offsets en el MARCO LOCAL del hueso) y lo aplica cada frame POR ENCIMA de
/// la animación ya evaluada — patrón BaileProcedural: TryGetBoneTransformAnimation
/// lee la pose SIN overrides (sin retroalimentación) y SetBoneOverride pisa.
/// Con la lista vacía no toca nada (la animación queda pura). En un bot
/// muerto (tag sbh_muerto) se apaga: el ragdoll manda.
/// OJO: comparte SceneModel.ClearBoneOverrides con otros drivers de huesos
/// (ej. BaileProcedural) — dos drivers vivos en el mismo bot se pisan entre sí.
/// </summary>
public sealed class HuesosManipulados : Component
{
	public struct Ajuste
	{
		public Angles Angulos;
		public Vector3 Posicion;
		public float Escala;

		public static readonly Ajuste Identidad = new() { Escala = 1f };

		public bool EsIdentidad =>
			Angulos == Angles.Zero && Posicion == Vector3.Zero && Escala == 1f;
	}

	readonly Dictionary<string, Ajuste> _ajustes = new();
	SkinnedModelRenderer _renderer;
	bool _aplicado;

	public IReadOnlyDictionary<string, Ajuste> Ajustes => _ajustes;

	public Ajuste De( string hueso )
		=> hueso != null && _ajustes.TryGetValue( hueso, out var a ) ? a : Ajuste.Identidad;

	public void Fijar( string hueso, Ajuste ajuste )
	{
		if ( string.IsNullOrEmpty( hueso ) ) return;

		if ( ajuste.EsIdentidad )
			_ajustes.Remove( hueso );
		else
			_ajustes[hueso] = ajuste;
	}

	public void Reiniciar() => _ajustes.Clear();

	protected override void OnEnabled()
	{
		_renderer = Components.Get<SkinnedModelRenderer>();
	}

	protected override void OnDisabled() => Limpiar();

	protected override void OnUpdate()
	{
		if ( !_renderer.IsValid() || _renderer.SceneModel == null ) return;

		if ( _ajustes.Count == 0 || GameObject.Tags.Has( "sbh_muerto" ) )
		{
			Limpiar();
			return;
		}

		var sm = _renderer.SceneModel;
		sm.ClearBoneOverrides();

		foreach ( var (nombre, ajuste) in _ajustes )
		{
			var hueso = _renderer.Model?.Bones?.GetBone( nombre );
			if ( hueso == null ) continue;

			// Pose de la ANIMACIÓN (mundo), sin nuestros overrides
			if ( !_renderer.TryGetBoneTransformAnimation( hueso, out var mundo ) ) continue;

			var local = sm.Transform.ToLocal( mundo );

			// Offsets en el marco local del hueso (como ManipulateBone* de GMod)
			local.Position += local.Rotation * ajuste.Posicion;
			local.Rotation *= Rotation.From( ajuste.Angulos );
			local.Scale *= ajuste.Escala;

			sm.SetBoneOverride( hueso.Index, local );
		}

		_aplicado = true;
	}

	void Limpiar()
	{
		if ( !_aplicado ) return;
		_aplicado = false;

		if ( _renderer.IsValid() && _renderer.SceneModel != null )
			_renderer.SceneModel.ClearBoneOverrides();
	}
}
ksbravo.sbhbase / Code/LibroFusiones.cs
Game library
namespace SBH;

using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// [SBHBase] El libro de recetas de fusión. Junta DOS fuentes:
///  - Las recetas de PACK: assets .fusion (RecetaFusion), solo lectura.
///  - Las recetas PROPIAS: creadas en el juego con la tool Merge en modo
///    crear; persisten como JSON en FileSystem.Data y sobreviven sesiones.
/// La tool y el recetario consumen Todas() sin distinguir origen (salvo
/// para permitir borrar: solo las propias).
/// </summary>
public static class LibroFusiones
{
	const string Archivo = "sbh_fusiones.json";

	/// <summary> Una receta lista para usar, venga del pack o del juego. </summary>
	public class Receta
	{
		public List<IngredienteFusion> Ingredientes { get; set; } = new();

		/// <summary> Ruta del prefab que aparece al fusionar. </summary>
		public string RutaResultado { get; set; } = "";

		/// <summary> Ruta de un SoundEvent opcional. </summary>
		public string RutaSonido { get; set; }

		/// <summary> Vino de un asset .fusion: no se puede borrar desde el juego. </summary>
		public bool DePack { get; set; }
	}

	static List<Receta> _propias;

	/// <summary> Sube con cada alta/baja de receta propia — para BuildHash de la UI. </summary>
	public static int Version { get; private set; }

	/// <summary> Todas las recetas válidas: primero las del pack, después las propias. </summary>
	public static IEnumerable<Receta> Todas()
	{
		foreach ( var r in ResourceLibrary.GetAll<RecetaFusion>() )
		{
			if ( r.Resultado == null ) continue;
			if ( r.Ingredientes == null || r.Ingredientes.Count == 0 ) continue;

			yield return new Receta
			{
				Ingredientes = r.Ingredientes,
				RutaResultado = r.Resultado.ResourcePath,
				RutaSonido = r.Sonido?.ResourcePath,
				DePack = true,
			};
		}

		foreach ( var r in Propias() )
			yield return r;
	}

	static List<Receta> Propias()
	{
		if ( _propias != null ) return _propias;

		_propias = new List<Receta>();

		try
		{
			if ( FileSystem.Data.FileExists( Archivo ) )
				_propias = Json.Deserialize<List<Receta>>( FileSystem.Data.ReadAllText( Archivo ) ) ?? new();
		}
		catch ( Exception e )
		{
			Log.Warning( $"LibroFusiones: no pude leer {Archivo} — {e.Message}" );
		}

		foreach ( var r in _propias )
			r.DePack = false;

		return _propias;
	}

	/// <summary>
	/// Guarda una receta creada en el juego. Si ya había una propia con los
	/// MISMOS ingredientes, se reemplaza — re-grabar una receta es la forma
	/// de cambiarle el resultado.
	/// </summary>
	public static void Guardar( Receta receta )
	{
		var propias = Propias();
		propias.RemoveAll( r => MismosIngredientes( r, receta ) );
		propias.Add( receta );
		Persistir();
	}

	/// <summary> Borra una receta propia (las de pack no se tocan). </summary>
	public static void Borrar( Receta receta )
	{
		if ( receta.DePack ) return;

		Propias().RemoveAll( r => MismosIngredientes( r, receta )
			&& r.RutaResultado == receta.RutaResultado );
		Persistir();
	}

	/// <summary> El botón nuclear: borra TODAS las recetas propias de un saque. </summary>
	public static void BorrarTodas()
	{
		Propias().Clear();
		Persistir();
	}

	static void Persistir()
	{
		try
		{
			FileSystem.Data.WriteAllText( Archivo, Json.Serialize( _propias ) );
		}
		catch ( Exception e )
		{
			Log.Warning( $"LibroFusiones: no pude guardar {Archivo} — {e.Message}" );
		}

		Version++;
	}

	// Mismo multiset de ingredientes (nombres sin mayúsculas, cantidades sumadas)
	static bool MismosIngredientes( Receta a, Receta b )
	{
		static Dictionary<string, int> Mapa( Receta r ) => r.Ingredientes
			.GroupBy( i => i.Nombre?.Trim().ToLowerInvariant() ?? "" )
			.ToDictionary( g => g.Key, g => g.Sum( i => Math.Max( 1, i.Cantidad ) ) );

		var ma = Mapa( a );
		var mb = Mapa( b );

		return ma.Count == mb.Count
			&& ma.All( kv => mb.TryGetValue( kv.Key, out var c ) && c == kv.Value );
	}
}
ksbravo.sbhbase / Code/RitmoInyector.cs
Game library
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;
	}
}
ksbravo.sbhbase / DirectorRitmo.cs
Game library
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// [SBHBase] El reloj del juego original, calcado del "loop controller"
/// del Sprunki de Scratch: un pulso cada 4.8 segundos (100 BPM, 2 compases
/// de 4/4) alternando "Loop 1" y "Loop 2". Los personajes RE-DISPARAN su
/// sonido en cada pulso — la sincronía es por retrigger, no hay loops
/// continuos que mantener en fase.
/// Cambio de fase (horror): corta todo, espera 2s y rearranca el reloj en
/// el pulso 0, igual que el broadcast "Phase 2" del original.
/// Es un GameObjectSystem: existe solo en toda escena, sin setup.
/// </summary>
public sealed class DirectorRitmo : GameObjectSystem
{
	/// <summary> Segundos entre pulsos: la variable Delay = 4.8 del original. </summary>
	public const float DuracionPulso = 4.8f;

	/// <summary> Silencio al cambiar de fase, como el wait 2 del original. </summary>
	public const float SilencioCambioFase = 2f;

	/// <summary> Número de pulso actual (0, 1, 2...); par = "Loop 1", impar = "Loop 2". -1 = todavía no arrancó. </summary>
	public int NumeroPulso { get; private set; } = -1;

	/// <summary> Fase horror activa (espejo estable de RitmoConVars.FaseHorror). </summary>
	public bool FaseHorror { get; private set; }

	/// <summary> Se dispara en cada pulso con su número. Los RitmoPersonaje se suscriben acá. </summary>
	public event Action<int> AlPulso;

	float _proximoPulso;
	float _silencioHasta = -1f;

	public DirectorRitmo( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, 10, Latir, "DirectorRitmoPersonaje" );
		_proximoPulso = Time.Now;
	}

	/// <summary> Avance 0..1 dentro del pulso actual (para sincronizar bailes). </summary>
	public float FraccionPulso => 1f - ((_proximoPulso - Time.Now) / DuracionPulso).Clamp( 0f, 1f );

	void Latir()
	{
		// Apagado: reloj detenido; al reactivar arranca limpio desde el pulso 0
		if ( !RitmoConVars.RitmoActivado )
		{
			NumeroPulso = -1;
			_proximoPulso = Time.Now;
			return;
		}

		// Cambio de fase estilo "Phase 2": corte, 2s de silencio y reloj a cero
		if ( RitmoConVars.FaseHorror != FaseHorror )
		{
			FaseHorror = RitmoConVars.FaseHorror;
			NumeroPulso = -1;
			_silencioHasta = Time.Now + SilencioCambioFase;
			_proximoPulso = Time.Now + SilencioCambioFase;
		}

		if ( Time.Now < _silencioHasta ) return;

		if ( Time.Now >= _proximoPulso )
		{
			NumeroPulso++;
			_proximoPulso += DuracionPulso;

			// Tras una pausa larga (editor, hitch) no disparar ráfagas de pulsos
			if ( Time.Now >= _proximoPulso )
				_proximoPulso = Time.Now + DuracionPulso;

			AlPulso?.Invoke( NumeroPulso );
		}
	}
}
ksbravo.sbhbase / HuesosAlSuelo.cs
Game library
namespace SBH;

using Sandbox;
using System.Collections.Generic;

/// <summary>
/// [SBHBase] Planta huesos al suelo — el "foot planting" de los gigantes que
/// caminan con las manos o arrastran la cabeza (pedido para el Sea Eater).
/// Cada frame toma la pose ANIMADA del hueso (patrón BaileProcedural:
/// TryGetBoneTransformAnimation, sin retroalimentación), busca el piso con
/// un rayo vertical y le clava la Z al terreno (offset + suavizado).
/// NO es IK de cadena (el codo no se dobla solo): es el truco de pegar el
/// hueso terminal al suelo — para patas y cabezas que arrastran, rinde.
/// OJO: comparte ClearBoneOverrides con los otros drivers de huesos
/// (HuesosManipulados, BaileProcedural) — un solo driver por bot a la vez.
/// </summary>
public sealed class HuesosAlSuelo : Component
{
	[Property, Description( "Huesos a plantar, separados por coma (ej: forearm.L_phy, forearm.R_phy)" )]
	public string Huesos { get; set; } = "";

	[Property, Description( "Offset sobre el piso en unidades (0 = apoyado justo; subilo si el hueso es el centro de una mano gorda)" )]
	public float Altura { get; set; } = 0f;

	[Property, Description( "Cuánto sube el rayo por encima del hueso antes de buscar piso" )]
	public float AlturaBusqueda { get; set; } = 256f;

	[Property, Description( "Cuánto puede bajar el rayo buscando piso; si no hay piso en ese tramo el hueso queda animado" )]
	public float Alcance { get; set; } = 2048f;

	[Property, Description( "Velocidad de acomodo (lerp exponencial); 0 = clavado duro" )]
	public float Suavizado { get; set; } = 10f;

	SkinnedModelRenderer _renderer;
	readonly Dictionary<string, float> _zSuave = new();
	bool _aplicado;

	protected override void OnEnabled()
	{
		_renderer = Components.Get<SkinnedModelRenderer>();
		_zSuave.Clear();
	}

	protected override void OnDisabled() => Limpiar();

	protected override void OnUpdate()
	{
		if ( !_renderer.IsValid() || _renderer.SceneModel == null ) return;

		// Muerto o ragdoll activo: la física manda, nada de plantar huesos
		// (doble cerrojo: el tag puede llegar un frame tarde o vivir en el
		// root — y ModelPhysics encendido es ragdoll seguro)
		if ( GameObject.Tags.Has( "sbh_muerto" ) || GameObject.Root.Tags.Has( "sbh_muerto" )
			|| Components.Get<ModelPhysics>() is not null )
		{
			Limpiar();
			return;
		}

		var sm = _renderer.SceneModel;
		sm.ClearBoneOverrides();
		_aplicado = false;

		foreach ( var crudo in (Huesos ?? "").Split( ',' ) )
		{
			var nombre = crudo.Trim();
			if ( nombre == "" ) continue;

			var hueso = _renderer.Model?.Bones?.GetBone( nombre );
			if ( hueso == null ) continue;
			if ( !_renderer.TryGetBoneTransformAnimation( hueso, out var mundo ) ) continue;

			// Piso bajo el hueso, ignorando al propio bot y a los demás bichos
			var piso = Scene.Trace
				.Ray( mundo.Position + Vector3.Up * AlturaBusqueda,
					  mundo.Position + Vector3.Down * Alcance )
				.IgnoreGameObjectHierarchy( GameObject.Root )
				.WithoutTags( "player", "sbh_nextbot" )
				.Run();

			if ( !piso.Hit ) continue;

			var zObjetivo = piso.EndPosition.z + Altura;
			if ( Suavizado > 0f && _zSuave.TryGetValue( nombre, out var zPrevio ) )
				zObjetivo = MathX.Lerp( zPrevio, zObjetivo, Time.Delta * Suavizado );
			_zSuave[nombre] = zObjetivo;

			mundo.Position = mundo.Position.WithZ( zObjetivo );
			sm.SetBoneOverride( hueso.Index, sm.Transform.ToLocal( mundo ) );
			_aplicado = true;
		}
	}

	void Limpiar()
	{
		if ( !_aplicado ) return;
		_aplicado = false;
		_zSuave.Clear();

		if ( _renderer.IsValid() && _renderer.SceneModel != null )
			_renderer.SceneModel.ClearBoneOverrides();
	}
}
ksbravo.sbhbase / HuesosManipulados.cs
Game library
namespace SBH;

using Sandbox;
using System.Collections.Generic;

/// <summary>
/// [SBHBase] Huesos manipulados a mano (los ManipulateBone* de GMod, para el
/// Advanced Bone Tool): guarda un ajuste por hueso (ángulos/posición/escala,
/// offsets en el MARCO LOCAL del hueso) y lo aplica cada frame POR ENCIMA de
/// la animación ya evaluada — patrón BaileProcedural: TryGetBoneTransformAnimation
/// lee la pose SIN overrides (sin retroalimentación) y SetBoneOverride pisa.
/// Con la lista vacía no toca nada (la animación queda pura). En un bot
/// muerto (tag sbh_muerto) se apaga: el ragdoll manda.
/// OJO: comparte SceneModel.ClearBoneOverrides con otros drivers de huesos
/// (ej. BaileProcedural) — dos drivers vivos en el mismo bot se pisan entre sí.
/// </summary>
public sealed class HuesosManipulados : Component
{
	public struct Ajuste
	{
		public Angles Angulos;
		public Vector3 Posicion;
		public float Escala;

		public static readonly Ajuste Identidad = new() { Escala = 1f };

		public bool EsIdentidad =>
			Angulos == Angles.Zero && Posicion == Vector3.Zero && Escala == 1f;
	}

	readonly Dictionary<string, Ajuste> _ajustes = new();
	SkinnedModelRenderer _renderer;
	bool _aplicado;

	public IReadOnlyDictionary<string, Ajuste> Ajustes => _ajustes;

	public Ajuste De( string hueso )
		=> hueso != null && _ajustes.TryGetValue( hueso, out var a ) ? a : Ajuste.Identidad;

	public void Fijar( string hueso, Ajuste ajuste )
	{
		if ( string.IsNullOrEmpty( hueso ) ) return;

		if ( ajuste.EsIdentidad )
			_ajustes.Remove( hueso );
		else
			_ajustes[hueso] = ajuste;
	}

	public void Reiniciar() => _ajustes.Clear();

	protected override void OnEnabled()
	{
		_renderer = Components.Get<SkinnedModelRenderer>();
	}

	protected override void OnDisabled() => Limpiar();

	protected override void OnUpdate()
	{
		if ( !_renderer.IsValid() || _renderer.SceneModel == null ) return;

		if ( _ajustes.Count == 0 || GameObject.Tags.Has( "sbh_muerto" ) )
		{
			Limpiar();
			return;
		}

		var sm = _renderer.SceneModel;
		sm.ClearBoneOverrides();

		foreach ( var (nombre, ajuste) in _ajustes )
		{
			var hueso = _renderer.Model?.Bones?.GetBone( nombre );
			if ( hueso == null ) continue;

			// Pose de la ANIMACIÓN (mundo), sin nuestros overrides
			if ( !_renderer.TryGetBoneTransformAnimation( hueso, out var mundo ) ) continue;

			var local = sm.Transform.ToLocal( mundo );

			// Offsets en el marco local del hueso (como ManipulateBone* de GMod)
			local.Position += local.Rotation * ajuste.Posicion;
			local.Rotation *= Rotation.From( ajuste.Angulos );
			local.Scale *= ajuste.Escala;

			sm.SetBoneOverride( hueso.Index, local );
		}

		_aplicado = true;
	}

	void Limpiar()
	{
		if ( !_aplicado ) return;
		_aplicado = false;

		if ( _renderer.IsValid() && _renderer.SceneModel != null )
			_renderer.SceneModel.ClearBoneOverrides();
	}
}
ksbravo.sbhbase / LibroFusiones.cs
Game library
namespace SBH;

using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// [SBHBase] El libro de recetas de fusión. Junta DOS fuentes:
///  - Las recetas de PACK: assets .fusion (RecetaFusion), solo lectura.
///  - Las recetas PROPIAS: creadas en el juego con la tool Merge en modo
///    crear; persisten como JSON en FileSystem.Data y sobreviven sesiones.
/// La tool y el recetario consumen Todas() sin distinguir origen (salvo
/// para permitir borrar: solo las propias).
/// </summary>
public static class LibroFusiones
{
	const string Archivo = "sbh_fusiones.json";

	/// <summary> Una receta lista para usar, venga del pack o del juego. </summary>
	public class Receta
	{
		public List<IngredienteFusion> Ingredientes { get; set; } = new();

		/// <summary> Ruta del prefab que aparece al fusionar. </summary>
		public string RutaResultado { get; set; } = "";

		/// <summary> Ruta de un SoundEvent opcional. </summary>
		public string RutaSonido { get; set; }

		/// <summary> Vino de un asset .fusion: no se puede borrar desde el juego. </summary>
		public bool DePack { get; set; }
	}

	static List<Receta> _propias;

	/// <summary> Sube con cada alta/baja de receta propia — para BuildHash de la UI. </summary>
	public static int Version { get; private set; }

	/// <summary> Todas las recetas válidas: primero las del pack, después las propias. </summary>
	public static IEnumerable<Receta> Todas()
	{
		foreach ( var r in ResourceLibrary.GetAll<RecetaFusion>() )
		{
			if ( r.Resultado == null ) continue;
			if ( r.Ingredientes == null || r.Ingredientes.Count == 0 ) continue;

			yield return new Receta
			{
				Ingredientes = r.Ingredientes,
				RutaResultado = r.Resultado.ResourcePath,
				RutaSonido = r.Sonido?.ResourcePath,
				DePack = true,
			};
		}

		foreach ( var r in Propias() )
			yield return r;
	}

	static List<Receta> Propias()
	{
		if ( _propias != null ) return _propias;

		_propias = new List<Receta>();

		try
		{
			if ( FileSystem.Data.FileExists( Archivo ) )
				_propias = Json.Deserialize<List<Receta>>( FileSystem.Data.ReadAllText( Archivo ) ) ?? new();
		}
		catch ( Exception e )
		{
			Log.Warning( $"LibroFusiones: no pude leer {Archivo} — {e.Message}" );
		}

		foreach ( var r in _propias )
			r.DePack = false;

		return _propias;
	}

	/// <summary>
	/// Guarda una receta creada en el juego. Si ya había una propia con los
	/// MISMOS ingredientes, se reemplaza — re-grabar una receta es la forma
	/// de cambiarle el resultado.
	/// </summary>
	public static void Guardar( Receta receta )
	{
		var propias = Propias();
		propias.RemoveAll( r => MismosIngredientes( r, receta ) );
		propias.Add( receta );
		Persistir();
	}

	/// <summary> Borra una receta propia (las de pack no se tocan). </summary>
	public static void Borrar( Receta receta )
	{
		if ( receta.DePack ) return;

		Propias().RemoveAll( r => MismosIngredientes( r, receta )
			&& r.RutaResultado == receta.RutaResultado );
		Persistir();
	}

	/// <summary> El botón nuclear: borra TODAS las recetas propias de un saque. </summary>
	public static void BorrarTodas()
	{
		Propias().Clear();
		Persistir();
	}

	static void Persistir()
	{
		try
		{
			FileSystem.Data.WriteAllText( Archivo, Json.Serialize( _propias ) );
		}
		catch ( Exception e )
		{
			Log.Warning( $"LibroFusiones: no pude guardar {Archivo} — {e.Message}" );
		}

		Version++;
	}

	// Mismo multiset de ingredientes (nombres sin mayúsculas, cantidades sumadas)
	static bool MismosIngredientes( Receta a, Receta b )
	{
		static Dictionary<string, int> Mapa( Receta r ) => r.Ingredientes
			.GroupBy( i => i.Nombre?.Trim().ToLowerInvariant() ?? "" )
			.ToDictionary( g => g.Key, g => g.Sum( i => Math.Max( 1, i.Cantidad ) ) );

		var ma = Mapa( a );
		var mb = Mapa( b );

		return ma.Count == mb.Count
			&& ma.All( kv => mb.TryGetValue( kv.Key, out var c ) && c == kv.Value );
	}
}
ksbravo.sbhbase / RitmoPersonaje.cs
Game library
namespace SBH;

using Sandbox;

/// <summary>
/// [SBHBase] El ritmo de un sprunki: en CADA pulso del DirectorRitmo
/// dispara su loop (variante A en "Loop 1", B en "Loop 2", como el juego
/// original) si está "en pantalla" (cerca de la cámara) y vivo. Cada
/// variante suena entonces cada 9.6s, intercaladas — el solape de sus colas
/// es lo que hace el loop continuo (clave en los sets horror de ~7s).
/// Re-disparar la MISMA variante corta su disparo anterior (semántica de
/// Scratch: "start sound" reinicia el sonido), así los audios que pasan de
/// 9.6s (creepy, better_windup...) se recortan solos como en el original.
/// No depende de SBHBase: detecta la muerte por la presencia del ragdoll
/// (ModelPhysics o el Rigidbody del plan B de MuerteNextbot).
/// </summary>
public sealed class RitmoPersonaje : Component
{
	[Property, Description( "Loop normal, variante A (pulsos 'Loop 1'). Vacío = personaje mudo en fase normal (como Black en el original)" )]
	public SoundEvent LoopNormalA { get; set; }

	[Property, Description( "Loop normal, variante B (pulsos 'Loop 2'). Vacío = repite la A" )]
	public SoundEvent LoopNormalB { get; set; }

	[Property, Description( "Loop de la fase horror, variante A. Vacío = sigue con el normal" )]
	public SoundEvent LoopHorrorA { get; set; }

	[Property, Description( "Loop de la fase horror, variante B. Vacío = repite la A" )]
	public SoundEvent LoopHorrorB { get; set; }

	[Property, Description( "Distancia máxima a la cámara para que dispare su ritmo — el 'estar en pantalla'" )]
	public float DistanciaAudible { get; set; } = 3000f;

	[Property, Description( "Segundos del fade de silencio al morir (0 = corte seco)" )]
	public float FadeMuerte { get; set; } = 0.35f;

	DirectorRitmo _director;

	// Un handle por variante (0 = A/pulsos pares, 1 = B/impares): igual que los
	// pares de sonidos del original (bass3/bass4, amen3/amen4...), cada slot se
	// reinicia a sí mismo cada 9.6s sin cortar al otro
	readonly SoundHandle[] _handles = new SoundHandle[2];
	readonly float[] _volumenes = { 1f, 1f }; // volumen al disparar (respeta el del .sound)
	float _fade;              // avance del fade de muerte
	bool _muriendo;

	protected override void OnEnabled()
	{
		_director = Scene.GetSystem<DirectorRitmo>();
		if ( _director != null )
			_director.AlPulso += EnPulso;
	}

	protected override void OnDisabled()
	{
		if ( _director != null )
			_director.AlPulso -= EnPulso;

		for ( var i = 0; i < _handles.Length; i++ )
		{
			_handles[i]?.Stop();
			_handles[i] = null;
		}
	}

	protected override void OnUpdate()
	{
		// Los loops viajan con el personaje (si no, quedan sonando donde arrancaron)
		for ( var i = 0; i < _handles.Length; i++ )
		{
			if ( _handles[i] != null && _handles[i].IsValid() )
				_handles[i].Position = WorldPosition;
		}

		// Muerte: el ritmo se apaga YA, con un fade cortito para que no haga
		// click — la cola musical no debe sobrevivir al ragdoll
		var muerto = EstaMuerto();

		if ( muerto && !_muriendo )
		{
			_muriendo = true;
			_fade = 0f;
		}
		else if ( !muerto )
		{
			_muriendo = false; // por si algo lo revive: vuelve a sonar
		}

		if ( _muriendo )
		{
			_fade += Time.Delta;
			var restante = FadeMuerte > 0f ? 1f - (_fade / FadeMuerte) : 0f;

			for ( var i = 0; i < _handles.Length; i++ )
			{
				if ( _handles[i] == null || !_handles[i].IsValid() ) continue;

				if ( restante <= 0f )
				{
					_handles[i].Stop();
					_handles[i] = null;
				}
				else
				{
					_handles[i].Volume = _volumenes[i] * restante;
				}
			}
		}
	}

	void EnPulso( int numero )
	{
		if ( !PuedeSonar() ) return;

		var horror = _director.FaseHorror && (LoopHorrorA != null || LoopHorrorB != null);

		// Cada pulso dispara: A en los pares ("Loop 1"), B en los impares
		// ("Loop 2"). Con B vacía, el mismo sonido ocupa los dos slots — igual
		// que los pares duplicados del original (amen3/amen4, kick/kick2)
		var esA = numero % 2 == 0;

		var sonido = Elegir( horror, esA );
		if ( sonido == null ) return;

		var slot = esA ? 0 : 1;

		// Re-disparar la variante REINICIA su disparo anterior (semántica
		// Scratch); la cola de la OTRA variante sigue sonando — ese solape
		// intercalado es el loop continuo del original
		_handles[slot]?.Stop();
		_handles[slot] = Sound.Play( sonido, WorldPosition );

		if ( _handles[slot] != null && _handles[slot].IsValid() )
			_volumenes[slot] = _handles[slot].Volume;

		// Aviso genérico "estoy cantando este pulso" (lo consume el puente
		// del juego para la cara de canto de las expresiones)
		AlCantar?.Invoke( DirectorRitmo.DuracionPulso );
	}

	/// <summary>
	/// Disparado en cada retrigger del loop, con los segundos que cubre hasta
	/// el próximo (un pulso, 4.8). Quien quiera animar el canto se cuelga acá.
	/// </summary>
	public event System.Action<float> AlCantar;

	SoundEvent Elegir( bool horror, bool esA )
	{
		if ( horror )
			return esA ? (LoopHorrorA ?? LoopHorrorB) : (LoopHorrorB ?? LoopHorrorA);

		return esA ? (LoopNormalA ?? LoopNormalB) : (LoopNormalB ?? LoopNormalA);
	}

	/// <summary>
	/// Cadáver = tag "sbh_muerto" (lo pone MuerteNextbot) o ragdoll activo.
	/// OJO: no mirar Rigidbody — un bot alzado por la physgun (AgarreNextbot)
	/// lleva Rigidbody temporal y está bien vivo: que siga sonando en el aire.
	/// </summary>
	bool EstaMuerto()
	{
		if ( GameObject.Tags.Has( "sbh_muerto" ) ) return true;
		if ( Components.Get<ModelPhysics>().IsValid() ) return true;
		return false;
	}

	bool PuedeSonar()
	{
		if ( EstaMuerto() ) return false;

		// "En pantalla": cerca de la cámara (el falloff del .sound hace el resto)
		var cam = Scene.Camera;
		if ( cam.IsValid() && cam.WorldPosition.Distance( WorldPosition ) > DistanciaAudible )
			return false;

		return true;
	}
}
ksbravo.sbhbase / Code/EventosAnim.cs
Game library
namespace SBH;

using Sandbox;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// [SBHBase] EVENTOS DE ANIMACIÓN — los animevents de GMod (Impact,
/// ImpactKill, sonidos...) como datos editables en vivo: por personaje, un
/// mapa anim → eventos en FRAMES a 30fps (el idioma de Blender y del QC).
///
/// Es el hermano del sistema de partituras de caras (PartituraStore), pero
/// la línea de tiempo es cada ANIMACIÓN en vez del pulso musical. Capas de
/// origen (mismo diseño): JSON local de FileSystem.Data (lo pinta el editor
/// en vivo, o Notepad mientras tanto) → de fábrica horneada en la librería
/// (EventosDeFabrica, tools/hornear_eventos.bat) → fallback del prefab
/// (RetardoImpacto clásico: quien no tiene eventos no cambia en nada).
///
/// Consumidores hoy: NextbotBrain (Impact de CADA variante del pool de
/// ataques — antes un solo RetardoImpacto desincronizaba a 3 de 4 golpes) y
/// SbhJumpscareCine (ImpactKill de execution). Mañana: pasos, efectos, y el
/// editor visual estilo Beat.
/// </summary>
public static class EventosAnim
{
	public class Evento
	{
		public string Anim { get; set; }
		public int Frame { get; set; }
		public string Nombre { get; set; }

		/// <summary> Parámetro opcional del evento: la ruta del .sound en Sound/Step; a futuro "prefab@hueso" en Efecto. </summary>
		public string Dato { get; set; }

		/// <summary>
		/// Capa visual del editor (0 = la primera). Eventos simultáneos viven
		/// en capas distintas para verse todos; el DESPACHADOR la ignora — se
		/// disparan todas las capas por igual. JSON viejos: sin el campo, 0.
		/// </summary>
		public int Capa { get; set; }
	}

	public class Datos
	{
		public List<Evento> Eventos { get; set; } = new();

		/// <summary>
		/// Head-tracking APAGADO para este personaje (2026-08-10): en rigs de
		/// GMod donde "head" es una parte especial — la tapa del libro de
		/// Buuik, la tapa del pastel de Cake — la mirada la retuerce feo.
		/// Lo togglea el editor de Anim Events y viaja con el horneado.
		/// </summary>
		public bool SinCabeza { get; set; }

		/// <summary>
		/// Fuerza del salto EN POSESIÓN de este personaje (2026-08-10).
		/// 0 = la que trae su prefab (SaltoNextbot.FuerzaSaltoPosesion, que
		/// escribe el addon de Blender). Se afina desde el editor Anim Events
		/// y viaja horneada, como todo lo demás.
		/// </summary>
		public float FuerzaSalto { get; set; }

		/// <summary>
		/// Velocidad de CAZA afinada (2026-08-10, "el Siren patina"): unidades
		/// por segundo al perseguir y al correr poseído. 0 = la del prefab
		/// (NavMeshAgent.MaxSpeed, que escribe el addon). Editor → JSON →
		/// horneado, la tripleta de siempre.
		/// </summary>
		public float Velocidad { get; set; }

		/// <summary>
		/// VelocidadAnimacion afinada (2026-08-10): a cuántas unidades/seg de
		/// suelo corresponde la anim de marcha a ritmo 1 — LA perilla del
		/// patinaje. 0 = la del prefab.
		/// </summary>
		public float VelocidadAnim { get; set; }

		/// <summary>
		/// Multiplicador de velocidad POR ANIMACIÓN (2026-08-10, la feature
		/// agendada): anim (minúsculas) → mult. 1.5 = "el run de este bicho
		/// va 50% más rápido, para siempre". Ausente = 1 (natural). Gana
		/// sobre el tope RitmoAnimMax y sobre la regla "ataque nunca
		/// acelerado": es la palabra explícita del artista.
		/// </summary>
		public Dictionary<string, float> RitmosAnim { get; set; } = new();

		/// <summary>
		/// Vida máxima afinada (2026-08-12, pedido de la primera reseña del
		/// juego): 0 = la del prefab (Vida.VidaMaxima). Se aplica al SPAWN —
		/// los bots ya paridos conservan la suya. Editor → JSON → horneado.
		/// </summary>
		public float Vida { get; set; }

		/// <summary>
		/// Daño por golpe afinado (2026-08-12): 0 = el del prefab
		/// (NextbotBrain.Danio). Se aplica al spawn, como la vida.
		/// </summary>
		public float Danio { get; set; }

		/// <summary>
		/// Super daño afinado (2026-08-12, el "Super Damage" de DRGBase):
		/// daño alternativo que sale con ProbDanioSuper. 0 = el del prefab.
		/// </summary>
		public float DanioSuper { get; set; }

		/// <summary> Probabilidad del super daño en % por golpe (0 = la del prefab). </summary>
		public float ProbDanioSuper { get; set; }

		/// <summary> Regeneración pasiva afinada, HP/seg (0 = la del prefab). </summary>
		public float Regeneracion { get; set; }

		/// <summary> Cooldown entre ataques afinado, en segundos (0 = el del prefab). </summary>
		public float CooldownAtaque { get; set; }
	}

	/// <summary> ¿Este personaje tiene el head-tracking apagado por datos? </summary>
	public static bool CabezaApagada( string personaje )
		=> !string.IsNullOrEmpty( personaje ) && Cargar( personaje ).SinCabeza;

	/// <summary> Fuerza de salto afinada para este personaje, o 0 si manda el prefab. </summary>
	public static float FuerzaSaltoDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).FuerzaSalto;

	/// <summary> Velocidad de caza afinada para este personaje, o 0 si manda el prefab. </summary>
	public static float VelocidadDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Velocidad;

	/// <summary> VelocidadAnimacion afinada para este personaje, o 0 si manda el prefab. </summary>
	public static float VelocidadAnimDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).VelocidadAnim;

	/// <summary> Vida máxima afinada para este personaje, o 0 si manda el prefab. </summary>
	public static float VidaDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Vida;

	/// <summary> Daño por golpe afinado para este personaje, o 0 si manda el prefab. </summary>
	public static float DanioDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Danio;

	/// <summary> Super daño afinado para este personaje, o 0 si manda el prefab. </summary>
	public static float DanioSuperDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).DanioSuper;

	/// <summary> Probabilidad (%) del super daño afinada, o 0 si manda el prefab. </summary>
	public static float ProbDanioSuperDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).ProbDanioSuper;

	/// <summary> Regeneración pasiva afinada (HP/seg), o 0 si manda el prefab. </summary>
	public static float RegeneracionDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Regeneracion;

	/// <summary> Cooldown de ataque afinado (seg), o 0 si manda el prefab. </summary>
	public static float CooldownDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).CooldownAtaque;

	/// <summary> Multiplicador de velocidad de UNA anim de este personaje (1 = natural). </summary>
	public static float RitmoAnimacionDe( string personaje, string anim )
	{
		if ( string.IsNullOrEmpty( personaje ) || string.IsNullOrEmpty( anim ) ) return 1f;

		var ritmos = Cargar( personaje ).RitmosAnim;
		if ( ritmos == null || ritmos.Count == 0 ) return 1f;

		return (ritmos.TryGetValue( anim, out var m ) || ritmos.TryGetValue( anim.ToLowerInvariant(), out m ))
			&& m > 0f ? m : 1f;
	}

	const string Carpeta = "sbh_animevents";
	const float Fps = 30f;

	static readonly Dictionary<string, Datos> _cache = new();

	/// <summary> El personaje según la convención de todo el proyecto: el nombre del vmdl. </summary>
	public static string PersonajeDe( SkinnedModelRenderer renderer )
		=> System.IO.Path.GetFileNameWithoutExtension( renderer?.Model?.ResourcePath ?? "" );

	static string Ruta( string personaje ) => $"{Carpeta}/{personaje}.json";

	public static Datos Cargar( string personaje )
	{
		if ( string.IsNullOrEmpty( personaje ) ) return new Datos();
		if ( _cache.TryGetValue( personaje, out var datos ) ) return datos;

		datos = FileSystem.Data.FileExists( Ruta( personaje ) )
			? FileSystem.Data.ReadJson<Datos>( Ruta( personaje ) )
			: null;

		// Sin JSON local mandan los eventos DE FÁBRICA horneados en la
		// librería (tools/hornear_eventos.bat): lo pintado acá viaja al
		// publicar y al equipo, igual que las partituras.
		datos ??= EventosDeFabrica.Obtener( personaje ) ?? new Datos();

		_cache[personaje] = datos;
		return datos;
	}

	public static void Guardar( string personaje, Datos datos )
	{
		FileSystem.Data.CreateDirectory( Carpeta );
		FileSystem.Data.WriteJson( Ruta( personaje ), datos );
		_cache[personaje] = datos;
	}

	/// <summary> El editor (o un reload a mano) invalida y los bots releen al próximo golpe. </summary>
	public static void Invalidar( string personaje = null )
	{
		if ( personaje == null ) _cache.Clear();
		else _cache.Remove( personaje );
	}

	/// <summary> ¿Esta anim tiene pintado al menos un evento de este nombre? </summary>
	public static bool TieneEvento( string personaje, string anim, string evento )
	{
		if ( string.IsNullOrEmpty( anim ) || string.IsNullOrEmpty( evento ) ) return false;

		return Cargar( personaje ).Eventos.Any( e =>
			string.Equals( e.Anim, anim, System.StringComparison.OrdinalIgnoreCase ) &&
			string.Equals( e.Nombre, evento, System.StringComparison.OrdinalIgnoreCase ) );
	}

	/// <summary>
	/// Segundos (a ritmo 1) del evento pedido en esa anim, o null si no está
	/// pintado — el consumidor cae a su fallback clásico.
	/// </summary>
	public static float? TiempoDe( string personaje, string anim, string evento )
	{
		if ( string.IsNullOrEmpty( anim ) || string.IsNullOrEmpty( evento ) ) return null;

		var ev = Cargar( personaje ).Eventos.FirstOrDefault( e =>
			string.Equals( e.Anim, anim, System.StringComparison.OrdinalIgnoreCase ) &&
			string.Equals( e.Nombre, evento, System.StringComparison.OrdinalIgnoreCase ) );

		return ev != null ? ev.Frame / Fps : null;
	}

	[ConCmd( "sbh_eventos_recargar", Help = "Relee los JSON de eventos de animación (sbh_animevents/) — para editar con Notepad sin reiniciar." )]
	public static void Recargar()
	{
		Invalidar();
		Log.Info( "🎞 EventosAnim: caché limpiada, los JSON se releen al próximo uso" );
	}
}
ksbravo.sbhbase / Code/PartiturasDeFabrica.cs
Game library
namespace SBH;

using System.Collections.Generic;

/// <summary>
/// [SBHBase] Partituras de fabrica - GENERADO por tools/hornear_partituras.bat
/// (2026-08-05 21:22) a partir de lo pintado en el editor Beat. NO editar a mano: pintar
/// en el juego y volver a hornear. Viajan con la libreria (publicacion y equipo);
/// el JSON local del jugador, si existe, las pisa (PartituraStore.Cargar).
/// Pistas por personaje: LoopA, LoopB, HorrorA, HorrorB (48 casilleros, -1 = vacio).
/// </summary>
public static class PartiturasDeFabrica
{
	static readonly Dictionary<string, int[][]> Tabla = new()
	{
		["sprunki_hrr_oren"] = new[]
		{
			new[] { 0, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, 0, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
		},
		["sprunki_jevin"] = new[]
		{
			new[] { 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 2, -1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, 3, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { 0, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 2, -1, -1, -1, -1, -1, -1, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
		},
		["sprunki_mrfuncomputer"] = new[]
		{
			new[] { 2, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, 3, -1, -1, -1, -1, -1, -1, 4, -1, -1, -1, -1, 5, -1, -1, -1, -1, -1, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 7, -1 },
			new[] { 3, -1, -1, -1, -1, 4, -1, -1, -1, -1, -1, 5, -1, -1, -1, -1, -1, 6, -1, -1, -1, -1, -1, 7, -1, -1, -1, -1, -1, 10, -1, -1, -1, -1, -1, 9, -1, -1, -1, -1, -1, 10, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
		},
		["sprunki_oren"] = new[]
		{
			new[] { -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
		},
		["sprunki_owakcx"] = new[]
		{
			new[] { 2, -1, -1, 0, -1, -1, 2, -1, 0, -1, -1, 2, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
			new[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },
		},
	};

	public static PartituraStore.Datos Obtener( string personaje )
	{
		if ( string.IsNullOrEmpty( personaje ) ) return null;
		if ( !Tabla.TryGetValue( personaje, out var pistas ) ) return null;

		return new PartituraStore.Datos
		{
			LoopA = new List<int>( pistas[0] ),
			LoopB = new List<int>( pistas[1] ),
			HorrorA = new List<int>( pistas[2] ),
			HorrorB = new List<int>( pistas[3] ),
		};
	}
}
ksbravo.sbhbase / Code/RespawnAlMorir.cs
Game library
namespace SBH;

using Sandbox;
using System.Linq;

/// <summary>
/// Al morir la Vida de este GameObject, lo teletransporta a un
/// SpawnPoint aleatorio y lo revive. Para jugadores.
/// </summary>
public sealed class RespawnAlMorir : Component
{
	[RequireComponent] Vida Vida { get; set; }

	[Property, Description( "Sonido al morir (opcional)" )]
	public SoundEvent SonidoMuerte { get; set; }

	protected override void OnEnabled()
	{
		Vida.AlMorir += Respawnear;
	}

	protected override void OnDisabled()
	{
		Vida.AlMorir -= Respawnear;
	}

	void Respawnear()
	{
		if ( SonidoMuerte != null )
			Sound.Play( SonidoMuerte, WorldPosition );

		var spawns = Scene.GetAllComponents<SpawnPoint>().ToList();
		if ( spawns.Count > 0 )
		{
			var spawn = Game.Random.FromList( spawns );
			WorldPosition = spawn.WorldPosition;
		}

		Vida.Revivir();
		Log.Info( "💀 Jugador muerto → respawn con vida completa" );
	}
}
ksbravo.sbhbase / Code/TemblorCamara.cs
Game library
namespace SBH;

using Sandbox;

/// <summary>
/// [SBHBase] TEMBLOR DE CÁMARA genérico (2026-08-10, pedido de Héctor para
/// los pisotones del Siren Head). Modelo de "trauma": los golpes suman
/// trauma, el trauma decae solo, y mientras haya trauma la vista vibra
/// (offset + roll aleatorios, curva cuadrática).
///
/// Es un ICameraModifier con el ORDEN MÁS ALTO: corre al final de la cadena
/// de cámara del engine, sobre la vista YA calculada del frame — primera
/// persona, posesión (órbita), cine — así que no hay realimentación posible
/// (la versión anterior mutaba la cámara post-update y la órbita de la
/// posesión heredaba la sacudida: "se buguea la cámara", 2026-08-10).
///
/// Cualquier sistema puede llamar <see cref="Sacudir"/>: pisadas de gigante,
/// explosiones, el trípode cuando vuelva del taller...
/// </summary>
public sealed class TemblorCamara : Component, ICameraModifier
{
	static TemblorCamara _instancia;
	float _trauma;

	/// <summary> Suma un golpe de temblor (0.1 = apenas, 1 = terremoto). </summary>
	public static void Sacudir( Scene scene, float intensidad )
	{
		if ( intensidad <= 0f ) return;
		var t = Asegurar( scene );
		if ( t.IsValid() )
			t._trauma = ( t._trauma + intensidad ).Clamp( 0f, 1.5f );
	}

	// Patrón FadeHorror.Asegurar: un solo GameObject de servicio en la escena
	static TemblorCamara Asegurar( Scene scene )
	{
		if ( _instancia.IsValid() ) return _instancia;
		if ( scene == null ) return null;

		var go = scene.CreateObject();
		go.Name = "sbh_temblor_camara";
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_instancia = go.Components.Create<TemblorCamara>();
		return _instancia;
	}

	int ICameraModifier.CameraOrder => 9999; // el último de la cadena

	void ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )
	{
		if ( _trauma <= 0.001f ) { _trauma = 0f; return; }

		var fuerza = _trauma * _trauma;
		view.Position += Vector3.Random * ( 6f * fuerza );
		view.Rotation *= Rotation.From(
			Game.Random.Float( -1.2f, 1.2f ) * fuerza,
			Game.Random.Float( -1.2f, 1.2f ) * fuerza,
			Game.Random.Float( -2.5f, 2.5f ) * fuerza );

		// Decae solo: un golpe de 0.4 dura ~un tercio de segundo
		_trauma = System.MathF.Max( 0f, _trauma - Time.Delta * 2.2f );
	}
}
ksbravo.sbhbase / EfectosNextbot.cs
Game library
namespace SBH;

using Sandbox;

/// <summary>
/// [SBHBase] Efectos de partículas del nextbot: al aparecer, al morir, al
/// recibir daño y cuando su golpe conecta. Cada efecto es un prefab one-shot
/// (emisor con Loop apagado y DestroyOnEnd encendido) que se clona en el
/// lugar y se limpia solo cuando las partículas terminan — mismo patrón que
/// los efectos del rollermine de sandbox. Cualquier campo vacío simplemente
/// no emite nada.
/// Requiere Vida en el mismo GameObject para muerte/daño y NextbotBrain
/// para el golpe conectado; sin ellos, esos efectos quedan mudos.
/// </summary>
public sealed class EfectosNextbot : Component
{
	[Property, Description( "Prefab de partículas al aparecer (se clona en los pies)" )]
	public GameObject EfectoSpawn { get; set; }

	[Property, Description( "Prefab de partículas al morir (se clona en el torso)" )]
	public GameObject EfectoMuerte { get; set; }

	[Property, Description( "Prefab de partículas al recibir daño, tipo sangre (en el torso; el golpe mortal usa solo el de muerte)" )]
	public GameObject EfectoDanio { get; set; }

	[Property, Description( "Prefab de partículas cuando SU golpe conecta (se clona en la víctima)" )]
	public GameObject EfectoGolpe { get; set; }

	[Property, Description( "Altura del torso sobre los pies, donde nacen los efectos de daño y muerte" )]
	public float AlturaTorso { get; set; } = 40f;

	Vida _vida;
	NextbotBrain _cerebro;

	Vector3 Torso => WorldPosition + Vector3.Up * AlturaTorso;

	protected override void OnEnabled()
	{
		_vida = Components.Get<Vida>();
		_cerebro = Components.Get<NextbotBrain>( true );

		if ( _vida.IsValid() )
		{
			_vida.AlRecibirDanio += AlDaniado;
			_vida.AlMorir += AlMorirBot;
		}

		if ( _cerebro.IsValid() )
			_cerebro.AlGolpear += AlConectarGolpe;
	}

	protected override void OnDisabled()
	{
		if ( _vida.IsValid() )
		{
			_vida.AlRecibirDanio -= AlDaniado;
			_vida.AlMorir -= AlMorirBot;
		}

		if ( _cerebro.IsValid() )
			_cerebro.AlGolpear -= AlConectarGolpe;
	}

	protected override void OnStart()
	{
		Clonar( EfectoSpawn, WorldPosition );
	}

	void AlDaniado( float cantidad )
	{
		// El golpe mortal ya tiene su propio efecto (AlMorir): no duplicar
		if ( _vida.IsValid() && _vida.EstaMuerto ) return;

		Clonar( EfectoDanio, Torso );
	}

	void AlMorirBot()
	{
		Clonar( EfectoMuerte, Torso );
	}

	void AlConectarGolpe( IVida victima, Vector3 punto )
	{
		Clonar( EfectoGolpe, punto );
	}

	static void Clonar( GameObject prefab, Vector3 posicion )
	{
		if ( prefab == null || !prefab.IsValid() ) return;

		var go = prefab.Clone( posicion );
		if ( !go.IsValid() ) return;

		// El DestroyOnEnd del emisor limpia las capas de partículas, pero el
		// objeto raíz del prefab quedaría huérfano en la escena para siempre
		// (TemporaryEffect: el mismo componente del engine que usan los gibs)
		go.Components.Create<TemporaryEffect>().DestroyAfterSeconds = 5f;
	}
}

ksbravo.sbhbase / PartituraStore.cs
Game library
namespace SBH;

using Sandbox;
using System.Collections.Generic;

/// <summary>
/// [SBHBase] Guardado de partituras de caras, una por personaje, en
/// FileSystem.Data (sprunki_partituras/&lt;personaje&gt;.json). El editor del
/// menú C guarda acá en cada pincelada; PartituraInyector carga al inyectar.
/// </summary>
public static class PartituraStore
{
	public class Datos
	{
		public List<int> LoopA { get; set; } = PartituraCaras.PistaVacia();
		public List<int> LoopB { get; set; } = PartituraCaras.PistaVacia();
		public List<int> HorrorA { get; set; } = PartituraCaras.PistaVacia();
		public List<int> HorrorB { get; set; } = PartituraCaras.PistaVacia();
	}

	const string Carpeta = "sprunki_partituras";

	static string Ruta( string personaje ) => $"{Carpeta}/{personaje}.json";

	public static Datos Cargar( string personaje )
	{
		if ( string.IsNullOrEmpty( personaje ) ) return null;

		// El JSON local (lo último pintado en el editor) manda; sin él, las
		// partituras de fábrica horneadas en la librería (tools/hornear_partituras)
		if ( !FileSystem.Data.FileExists( Ruta( personaje ) ) )
			return DeFabrica( personaje );

		var datos = FileSystem.Data.ReadJson<Datos>( Ruta( personaje ) );
		if ( datos == null ) return DeFabrica( personaje );

		Normalizar( datos );
		return datos;
	}

	static Datos DeFabrica( string personaje )
	{
		var datos = PartiturasDeFabrica.Obtener( personaje );
		if ( datos != null ) Normalizar( datos );
		return datos;
	}

	public static void Guardar( string personaje, Datos datos )
	{
		if ( string.IsNullOrEmpty( personaje ) || datos == null ) return;

		FileSystem.Data.CreateDirectory( Carpeta );
		FileSystem.Data.WriteJson( Ruta( personaje ), datos );
	}

	/// <summary> Vuelca los datos al componente vivo (copias, no referencias compartidas). </summary>
	public static void AplicarA( PartituraCaras partitura, Datos datos )
	{
		if ( !partitura.IsValid() || datos == null ) return;

		partitura.LoopA = new List<int>( datos.LoopA );
		partitura.LoopB = new List<int>( datos.LoopB );
		partitura.HorrorA = new List<int>( datos.HorrorA );
		partitura.HorrorB = new List<int>( datos.HorrorB );
	}

	/// <summary> Pistas siempre de 48 casilleros, venga como venga el JSON. </summary>
	static void Normalizar( Datos datos )
	{
		datos.LoopA = Ajustar( datos.LoopA );
		datos.LoopB = Ajustar( datos.LoopB );
		datos.HorrorA = Ajustar( datos.HorrorA );
		datos.HorrorB = Ajustar( datos.HorrorB );
	}

	static List<int> Ajustar( List<int> pista )
	{
		pista ??= new List<int>();
		while ( pista.Count < PartituraCaras.Casilleros ) pista.Add( -1 );
		if ( pista.Count > PartituraCaras.Casilleros )
			pista.RemoveRange( PartituraCaras.Casilleros, pista.Count - PartituraCaras.Casilleros );
		return pista;
	}
}
ksbravo.sbhbase / TemblorCamara.cs
Game library
namespace SBH;

using Sandbox;

/// <summary>
/// [SBHBase] TEMBLOR DE CÁMARA genérico (2026-08-10, pedido de Héctor para
/// los pisotones del Siren Head). Modelo de "trauma": los golpes suman
/// trauma, el trauma decae solo, y mientras haya trauma la vista vibra
/// (offset + roll aleatorios, curva cuadrática).
///
/// Es un ICameraModifier con el ORDEN MÁS ALTO: corre al final de la cadena
/// de cámara del engine, sobre la vista YA calculada del frame — primera
/// persona, posesión (órbita), cine — así que no hay realimentación posible
/// (la versión anterior mutaba la cámara post-update y la órbita de la
/// posesión heredaba la sacudida: "se buguea la cámara", 2026-08-10).
///
/// Cualquier sistema puede llamar <see cref="Sacudir"/>: pisadas de gigante,
/// explosiones, el trípode cuando vuelva del taller...
/// </summary>
public sealed class TemblorCamara : Component, ICameraModifier
{
	static TemblorCamara _instancia;
	float _trauma;

	/// <summary> Suma un golpe de temblor (0.1 = apenas, 1 = terremoto). </summary>
	public static void Sacudir( Scene scene, float intensidad )
	{
		if ( intensidad <= 0f ) return;
		var t = Asegurar( scene );
		if ( t.IsValid() )
			t._trauma = ( t._trauma + intensidad ).Clamp( 0f, 1.5f );
	}

	// Patrón FadeHorror.Asegurar: un solo GameObject de servicio en la escena
	static TemblorCamara Asegurar( Scene scene )
	{
		if ( _instancia.IsValid() ) return _instancia;
		if ( scene == null ) return null;

		var go = scene.CreateObject();
		go.Name = "sbh_temblor_camara";
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_instancia = go.Components.Create<TemblorCamara>();
		return _instancia;
	}

	int ICameraModifier.CameraOrder => 9999; // el último de la cadena

	void ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )
	{
		if ( _trauma <= 0.001f ) { _trauma = 0f; return; }

		var fuerza = _trauma * _trauma;
		view.Position += Vector3.Random * ( 6f * fuerza );
		view.Rotation *= Rotation.From(
			Game.Random.Float( -1.2f, 1.2f ) * fuerza,
			Game.Random.Float( -1.2f, 1.2f ) * fuerza,
			Game.Random.Float( -2.5f, 2.5f ) * fuerza );

		// Decae solo: un golpe de 0.4 dura ~un tercio de segundo
		_trauma = System.MathF.Max( 0f, _trauma - Time.Delta * 2.2f );
	}
}
ksbravo.sbhbase / Code/PercepcionNextbot.cs
Game library
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// Filtro de percepción por objetivo, para que los packs modifiquen los
/// sentidos sin tocar la base (el sigilo de Toy Zombies: te ven según tu
/// velocidad y tu linterna). Un componente del mismo GameObject lo
/// implementa y PercepcionNextbot lo consulta en cada chequeo.
/// </summary>
public interface IFiltroPercepcion
{
	/// <summary>
	/// ¿Puede percibir a este objetivo a esta distancia? true = sí (aunque
	/// esté fuera del cono: el ruido delata — la línea de visión igual se
	/// exige), false = no (silencioso/invisible), null = sin opinión (siguen
	/// las reglas normales de la base).
	/// </summary>
	bool? PuedeVer( IVida objetivo, float distancia );
}

/// <summary>
/// [SBHBase] Sentidos del nextbot: vista en cono con línea de visión real
/// (las paredes bloquean) y oído de corto alcance en cualquier dirección.
/// Recuerda al objetivo un rato después de perderlo de vista, guardando su
/// última posición conocida, y lo olvida pasado TiempoOlvido.
/// Si un GameObject no tiene este componente, NextbotBrain vuelve al
/// comportamiento omnisciente clásico.
/// </summary>
public sealed class PercepcionNextbot : Component
{
	// ---------- Configurable desde el Inspector ----------
	[Property, Description( "Distancia máxima de visión" )]
	public float RangoVista { get; set; } = 1500f;

	[Property, Description( "Ángulo total del cono de visión, en grados" )]
	public float AnguloVista { get; set; } = 140f;

	[Property, Description( "Distancia a la que siente enemigos aunque estén fuera del cono de visión (una pared igual lo bloquea)" )]
	public float RangoOido { get; set; } = 150f;

	[Property, Description( "Altura de los ojos respecto al piso del nextbot" )]
	public float AlturaOjos { get; set; } = 60f;

	[Property, Description( "Segundos que recuerda al objetivo tras perderlo de vista" )]
	public float TiempoOlvido { get; set; } = 5f;

	[Property, Description( "Segundos entre escaneos de enemigos (rendimiento)" )]
	public float IntervaloEscaneo { get; set; } = 0.2f;

	[Property, Description( "Multiplicador del rango de visión mientras está en alerta (tras recibir daño)" )]
	public float MultiplicadorAlerta { get; set; } = 2f;

	[Property, Description( "Segundos que dura la alerta tras recibir daño (cada golpe la renueva)" )]
	public float DuracionAlerta { get; set; } = 15f;

	// ---------- Estado que consumen otros componentes ----------
	/// <summary>
	/// Omnisciencia temporal: mientras esté en true percibe a TODOS sus
	/// enemigos sin cono, sin paredes y sin límite de distancia (el
	/// SetOmniscient de DRGBase). La prenden skills de pack — el grito de
	/// manada del Mono — y la apagan ellas mismas al terminar.
	/// </summary>
	public bool ModoOmnisciente { get; set; }

	/// <summary> Enemigo actual (percibido ahora o recordado). Null si no hay. </summary>
	public IVida ObjetivoActual { get; private set; }

	/// <summary> ¿Está percibiendo al objetivo AHORA MISMO? </summary>
	public bool ObjetivoVisible { get; private set; }

	/// <summary> Último lugar donde percibió al objetivo. Válido si ObjetivoActual no es null. </summary>
	public Vector3 UltimaPosicionConocida { get; private set; }

	/// <summary> Posición de los ojos, para traces de visión. </summary>
	public Vector3 Ojos => WorldPosition + Vector3.Up * AlturaOjos;

	/// <summary> ¿Está en alerta? (lo dañaron hace poco: ve más lejos mientras busca) </summary>
	public bool EnAlerta => _desdeAlerta < DuracionAlerta;

	/// <summary> Rango de visión vigente: el normal, o el ampliado si está en alerta. </summary>
	public float RangoVistaEfectivo => EnAlerta ? RangoVista * MultiplicadorAlerta : RangoVista;

	TimeSince _desdeEscaneo;
	TimeSince _desdeVisto;
	TimeSince _desdeAlerta;
	Vida _vida;
	NextbotBrain _cerebro;

	// La IA de este bot está apagada, sea por el switch global o por la
	// suspensión individual del cerebro (tecla apuntándolo). Sin sentidos ni
	// memoria — y de rebote SaltoNextbot tampoco salta (bebe de ObjetivoActual)
	bool IaApagada => !SbhConVars.IaActivada
		|| (_cerebro.IsValid() && _cerebro.IaSuspendida);

	protected override void OnStart()
	{
		_desdeEscaneo = 999f;
		_desdeVisto = 999f;
		_desdeAlerta = 999f;
	}

	protected override void OnEnabled()
	{
		_vida = Components.Get<Vida>();
		_cerebro = Components.Get<NextbotBrain>();
		if ( _vida.IsValid() )
			_vida.AlRecibirDanio += AlSerDaniado;
	}

	protected override void OnDisabled()
	{
		if ( _vida.IsValid() )
			_vida.AlRecibirDanio -= AlSerDaniado;
	}

	/// <summary>
	/// Venganza: al recibir daño, fija como objetivo a quien lo hizo aunque
	/// esté fuera de los sentidos (un francotirador por la espalda, p. ej.).
	/// Sabe DÓNDE estaba el atacante, no necesariamente lo ve.
	/// </summary>
	void AlSerDaniado( float cantidad )
	{
		var atacante = _vida?.UltimoAtacante;
		if ( !atacante.IsValid() || atacante == GameObject ) return;

		// El atacante puede ser el arma (hija del jugador): buscar hacia arriba
		var objetivo = atacante.Components.Get<IVida>( FindMode.EverythingInSelfAndAncestors );

		if ( objetivo == null || objetivo.EstaMuerto ) return;
		if ( IaApagada ) return;
		if ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return; // notarget total, ni con balazos

		// Alerta: mientras busca al agresor ve más lejos; se calma sola si no lo encuentra
		_desdeAlerta = 0f;

		ObjetivoActual = objetivo;
		ObjetivoVisible = false;
		UltimaPosicionConocida = objetivo.WorldPosition;
		_desdeVisto = 0f;
	}

	protected override void OnUpdate()
	{
		if ( _desdeEscaneo < IntervaloEscaneo ) return;
		_desdeEscaneo = 0f;

		Escanear();
	}

	void Escanear()
	{
		// IA apagada (global o suspensión individual): sin sentidos ni memoria
		if ( IaApagada )
		{
			Olvidar();
			return;
		}

		// Los muertos se olvidan al instante; los jugadores también si ahora los
		// ignoramos, y lo mismo quien salió del combate (posesión, cinemáticas)
		if ( ObjetivoActual != null && (!ObjetivoActual.IsValid() || ObjetivoActual.EstaMuerto
			|| ObjetivoActual.FueraDeCombate
			|| (SbhConVars.IgnorarJugadores && ObjetivoActual.EsJugador)) )
			Olvidar();

		// Buscar el enemigo percibido más cercano
		var miFaccion = Components.Get<Faccion>();

		IVida mejor = null;
		var mejorDistancia = float.MaxValue;

		foreach ( var vida in Vidas.Todas( Scene ) )
		{
			if ( vida.GameObject == GameObject ) continue;
			if ( vida.EstaMuerto ) continue;
			if ( vida.FueraDeCombate ) continue;
			if ( SbhConVars.IgnorarJugadores && vida.EsJugador ) continue;
			if ( !Faccion.SonEnemigos( miFaccion, vida.GameObject.Components.Get<Faccion>() ) ) continue;
			if ( !Percibe( vida ) ) continue;

			var distancia = vida.WorldPosition.Distance( WorldPosition );
			if ( distancia < mejorDistancia )
			{
				mejorDistancia = distancia;
				mejor = vida;
			}
		}

		if ( mejor != null )
		{
			ObjetivoActual = mejor;
			ObjetivoVisible = true;
			UltimaPosicionConocida = mejor.WorldPosition;
			_desdeVisto = 0f;
			return;
		}

		// No percibe a nadie: mantener el recuerdo un rato y después olvidar
		ObjetivoVisible = false;

		if ( ObjetivoActual != null && _desdeVisto > TiempoOlvido )
			Olvidar();
	}

	/// <summary>
	/// Le marca un objetivo desde afuera, aunque no lo haya percibido: el
	/// equivalente al SpotEntity/SetEnemy de DRGBase. Lo usan las skills de
	/// manada (el grito del Mono señala a la víctima para todo el pack).
	/// Entra como recuerdo — el escaneo siguiente decide si además lo ve.
	/// </summary>
	public void Marcar( IVida objetivo )
	{
		if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;
		if ( IaApagada ) return;
		if ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return;

		ObjetivoActual = objetivo;
		ObjetivoVisible = false;
		UltimaPosicionConocida = objetivo.WorldPosition;
		_desdeVisto = 0f;
	}

	/// <summary> ¿Este enemigo entra por alguno de los sentidos? </summary>
	public bool Percibe( IVida objetivo )
	{
		// Omnisciente: lo ve esté donde esté, incluso atrás y tras una pared
		// (pisa también al filtro: el grito de manada anula el sigilo).
		// Por skill (GritoManada) o por el interruptor GLOBAL de la base.
		if ( ModoOmnisciente || SbhConVars.Omnisciencia ) return true;

		var distancia = objetivo.WorldPosition.Distance( WorldPosition );

		// Filtro del pack (sigilo): false = no lo percibe por más cerca que
		// esté; true = percibido aunque esté fuera del cono (el ruido delata
		// la dirección), pero una pared igual lo esconde
		var filtro = Components.Get<IFiltroPercepcion>();
		if ( filtro != null )
		{
			var veredicto = filtro.PuedeVer( objetivo, distancia );
			if ( veredicto == false ) return false;
			if ( veredicto == true )
				return distancia <= RangoVistaEfectivo && TieneLineaDeVision( objetivo );
		}

		// Oído: corto alcance sin importar el ángulo, pero las paredes
		// SÍ bloquean (si no, es un radar y se siente omnisciente)
		if ( distancia <= RangoOido )
			return TieneLineaDeVision( objetivo );

		// Vista: alcance + cono + línea de visión libre
		if ( distancia > RangoVistaEfectivo )
			return false;

		var direccion = (objetivo.WorldPosition - WorldPosition).WithZ( 0 );
		if ( direccion.Length > 1f )
		{
			var cosLimite = MathF.Cos( AnguloVista * 0.5f * MathF.PI / 180f );
			var cosAngulo = Vector3.Dot( WorldRotation.Forward.WithZ( 0 ).Normal, direccion.Normal );
			if ( cosAngulo < cosLimite )
				return false;
		}

		return TieneLineaDeVision( objetivo );
	}

	bool TieneLineaDeVision( IVida objetivo )
	{
		// Apuntar al torso del objetivo, no a sus pies
		var puntoObjetivo = objetivo.WorldPosition + Vector3.Up * 40f;

		var trace = Scene.Trace.Ray( Ojos, puntoObjetivo )
			.IgnoreGameObjectHierarchy( GameObject )
			.IgnoreGameObjectHierarchy( objetivo.GameObject )
			.Run();

		// Si el rayo no chocó con nada en el camino, lo ve
		return !trace.Hit;
	}

	public void Olvidar()
	{
		ObjetivoActual = null;
		ObjetivoVisible = false;
	}
}
ksbravo.sbhbase / PercepcionNextbot.cs
Game library
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// Filtro de percepción por objetivo, para que los packs modifiquen los
/// sentidos sin tocar la base (el sigilo de Toy Zombies: te ven según tu
/// velocidad y tu linterna). Un componente del mismo GameObject lo
/// implementa y PercepcionNextbot lo consulta en cada chequeo.
/// </summary>
public interface IFiltroPercepcion
{
	/// <summary>
	/// ¿Puede percibir a este objetivo a esta distancia? true = sí (aunque
	/// esté fuera del cono: el ruido delata — la línea de visión igual se
	/// exige), false = no (silencioso/invisible), null = sin opinión (siguen
	/// las reglas normales de la base).
	/// </summary>
	bool? PuedeVer( IVida objetivo, float distancia );
}

/// <summary>
/// [SBHBase] Sentidos del nextbot: vista en cono con línea de visión real
/// (las paredes bloquean) y oído de corto alcance en cualquier dirección.
/// Recuerda al objetivo un rato después de perderlo de vista, guardando su
/// última posición conocida, y lo olvida pasado TiempoOlvido.
/// Si un GameObject no tiene este componente, NextbotBrain vuelve al
/// comportamiento omnisciente clásico.
/// </summary>
public sealed class PercepcionNextbot : Component
{
	// ---------- Configurable desde el Inspector ----------
	[Property, Description( "Distancia máxima de visión" )]
	public float RangoVista { get; set; } = 1500f;

	[Property, Description( "Ángulo total del cono de visión, en grados" )]
	public float AnguloVista { get; set; } = 140f;

	[Property, Description( "Distancia a la que siente enemigos aunque estén fuera del cono de visión (una pared igual lo bloquea)" )]
	public float RangoOido { get; set; } = 150f;

	[Property, Description( "Altura de los ojos respecto al piso del nextbot" )]
	public float AlturaOjos { get; set; } = 60f;

	[Property, Description( "Segundos que recuerda al objetivo tras perderlo de vista" )]
	public float TiempoOlvido { get; set; } = 5f;

	[Property, Description( "Segundos entre escaneos de enemigos (rendimiento)" )]
	public float IntervaloEscaneo { get; set; } = 0.2f;

	[Property, Description( "Multiplicador del rango de visión mientras está en alerta (tras recibir daño)" )]
	public float MultiplicadorAlerta { get; set; } = 2f;

	[Property, Description( "Segundos que dura la alerta tras recibir daño (cada golpe la renueva)" )]
	public float DuracionAlerta { get; set; } = 15f;

	// ---------- Estado que consumen otros componentes ----------
	/// <summary>
	/// Omnisciencia temporal: mientras esté en true percibe a TODOS sus
	/// enemigos sin cono, sin paredes y sin límite de distancia (el
	/// SetOmniscient de DRGBase). La prenden skills de pack — el grito de
	/// manada del Mono — y la apagan ellas mismas al terminar.
	/// </summary>
	public bool ModoOmnisciente { get; set; }

	/// <summary> Enemigo actual (percibido ahora o recordado). Null si no hay. </summary>
	public IVida ObjetivoActual { get; private set; }

	/// <summary> ¿Está percibiendo al objetivo AHORA MISMO? </summary>
	public bool ObjetivoVisible { get; private set; }

	/// <summary> Último lugar donde percibió al objetivo. Válido si ObjetivoActual no es null. </summary>
	public Vector3 UltimaPosicionConocida { get; private set; }

	/// <summary> Posición de los ojos, para traces de visión. </summary>
	public Vector3 Ojos => WorldPosition + Vector3.Up * AlturaOjos;

	/// <summary> ¿Está en alerta? (lo dañaron hace poco: ve más lejos mientras busca) </summary>
	public bool EnAlerta => _desdeAlerta < DuracionAlerta;

	/// <summary> Rango de visión vigente: el normal, o el ampliado si está en alerta. </summary>
	public float RangoVistaEfectivo => EnAlerta ? RangoVista * MultiplicadorAlerta : RangoVista;

	TimeSince _desdeEscaneo;
	TimeSince _desdeVisto;
	TimeSince _desdeAlerta;
	Vida _vida;
	NextbotBrain _cerebro;

	// La IA de este bot está apagada, sea por el switch global o por la
	// suspensión individual del cerebro (tecla apuntándolo). Sin sentidos ni
	// memoria — y de rebote SaltoNextbot tampoco salta (bebe de ObjetivoActual)
	bool IaApagada => !SbhConVars.IaActivada
		|| (_cerebro.IsValid() && _cerebro.IaSuspendida);

	protected override void OnStart()
	{
		_desdeEscaneo = 999f;
		_desdeVisto = 999f;
		_desdeAlerta = 999f;
	}

	protected override void OnEnabled()
	{
		_vida = Components.Get<Vida>();
		_cerebro = Components.Get<NextbotBrain>();
		if ( _vida.IsValid() )
			_vida.AlRecibirDanio += AlSerDaniado;
	}

	protected override void OnDisabled()
	{
		if ( _vida.IsValid() )
			_vida.AlRecibirDanio -= AlSerDaniado;
	}

	/// <summary>
	/// Venganza: al recibir daño, fija como objetivo a quien lo hizo aunque
	/// esté fuera de los sentidos (un francotirador por la espalda, p. ej.).
	/// Sabe DÓNDE estaba el atacante, no necesariamente lo ve.
	/// </summary>
	void AlSerDaniado( float cantidad )
	{
		var atacante = _vida?.UltimoAtacante;
		if ( !atacante.IsValid() || atacante == GameObject ) return;

		// El atacante puede ser el arma (hija del jugador): buscar hacia arriba
		var objetivo = atacante.Components.Get<IVida>( FindMode.EverythingInSelfAndAncestors );

		if ( objetivo == null || objetivo.EstaMuerto ) return;
		if ( IaApagada ) return;
		if ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return; // notarget total, ni con balazos

		// Alerta: mientras busca al agresor ve más lejos; se calma sola si no lo encuentra
		_desdeAlerta = 0f;

		ObjetivoActual = objetivo;
		ObjetivoVisible = false;
		UltimaPosicionConocida = objetivo.WorldPosition;
		_desdeVisto = 0f;
	}

	protected override void OnUpdate()
	{
		if ( _desdeEscaneo < IntervaloEscaneo ) return;
		_desdeEscaneo = 0f;

		Escanear();
	}

	void Escanear()
	{
		// IA apagada (global o suspensión individual): sin sentidos ni memoria
		if ( IaApagada )
		{
			Olvidar();
			return;
		}

		// Los muertos se olvidan al instante; los jugadores también si ahora los
		// ignoramos, y lo mismo quien salió del combate (posesión, cinemáticas)
		if ( ObjetivoActual != null && (!ObjetivoActual.IsValid() || ObjetivoActual.EstaMuerto
			|| ObjetivoActual.FueraDeCombate
			|| (SbhConVars.IgnorarJugadores && ObjetivoActual.EsJugador)) )
			Olvidar();

		// Buscar el enemigo percibido más cercano
		var miFaccion = Components.Get<Faccion>();

		IVida mejor = null;
		var mejorDistancia = float.MaxValue;

		foreach ( var vida in Vidas.Todas( Scene ) )
		{
			if ( vida.GameObject == GameObject ) continue;
			if ( vida.EstaMuerto ) continue;
			if ( vida.FueraDeCombate ) continue;
			if ( SbhConVars.IgnorarJugadores && vida.EsJugador ) continue;
			if ( !Faccion.SonEnemigos( miFaccion, vida.GameObject.Components.Get<Faccion>() ) ) continue;
			if ( !Percibe( vida ) ) continue;

			var distancia = vida.WorldPosition.Distance( WorldPosition );
			if ( distancia < mejorDistancia )
			{
				mejorDistancia = distancia;
				mejor = vida;
			}
		}

		if ( mejor != null )
		{
			ObjetivoActual = mejor;
			ObjetivoVisible = true;
			UltimaPosicionConocida = mejor.WorldPosition;
			_desdeVisto = 0f;
			return;
		}

		// No percibe a nadie: mantener el recuerdo un rato y después olvidar
		ObjetivoVisible = false;

		if ( ObjetivoActual != null && _desdeVisto > TiempoOlvido )
			Olvidar();
	}

	/// <summary>
	/// Le marca un objetivo desde afuera, aunque no lo haya percibido: el
	/// equivalente al SpotEntity/SetEnemy de DRGBase. Lo usan las skills de
	/// manada (el grito del Mono señala a la víctima para todo el pack).
	/// Entra como recuerdo — el escaneo siguiente decide si además lo ve.
	/// </summary>
	public void Marcar( IVida objetivo )
	{
		if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;
		if ( IaApagada ) return;
		if ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return;

		ObjetivoActual = objetivo;
		ObjetivoVisible = false;
		UltimaPosicionConocida = objetivo.WorldPosition;
		_desdeVisto = 0f;
	}

	/// <summary> ¿Este enemigo entra por alguno de los sentidos? </summary>
	public bool Percibe( IVida objetivo )
	{
		// Omnisciente: lo ve esté donde esté, incluso atrás y tras una pared
		// (pisa también al filtro: el grito de manada anula el sigilo).
		// Por skill (GritoManada) o por el interruptor GLOBAL de la base.
		if ( ModoOmnisciente || SbhConVars.Omnisciencia ) return true;

		var distancia = objetivo.WorldPosition.Distance( WorldPosition );

		// Filtro del pack (sigilo): false = no lo percibe por más cerca que
		// esté; true = percibido aunque esté fuera del cono (el ruido delata
		// la dirección), pero una pared igual lo esconde
		var filtro = Components.Get<IFiltroPercepcion>();
		if ( filtro != null )
		{
			var veredicto = filtro.PuedeVer( objetivo, distancia );
			if ( veredicto == false ) return false;
			if ( veredicto == true )
				return distancia <= RangoVistaEfectivo && TieneLineaDeVision( objetivo );
		}

		// Oído: corto alcance sin importar el ángulo, pero las paredes
		// SÍ bloquean (si no, es un radar y se siente omnisciente)
		if ( distancia <= RangoOido )
			return TieneLineaDeVision( objetivo );

		// Vista: alcance + cono + línea de visión libre
		if ( distancia > RangoVistaEfectivo )
			return false;

		var direccion = (objetivo.WorldPosition - WorldPosition).WithZ( 0 );
		if ( direccion.Length > 1f )
		{
			var cosLimite = MathF.Cos( AnguloVista * 0.5f * MathF.PI / 180f );
			var cosAngulo = Vector3.Dot( WorldRotation.Forward.WithZ( 0 ).Normal, direccion.Normal );
			if ( cosAngulo < cosLimite )
				return false;
		}

		return TieneLineaDeVision( objetivo );
	}

	bool TieneLineaDeVision( IVida objetivo )
	{
		// Apuntar al torso del objetivo, no a sus pies
		var puntoObjetivo = objetivo.WorldPosition + Vector3.Up * 40f;

		var trace = Scene.Trace.Ray( Ojos, puntoObjetivo )
			.IgnoreGameObjectHierarchy( GameObject )
			.IgnoreGameObjectHierarchy( objetivo.GameObject )
			.Run();

		// Si el rayo no chocó con nada en el camino, lo ve
		return !trace.Hit;
	}

	public void Olvidar()
	{
		ObjetivoActual = null;
		ObjetivoVisible = false;
	}
}
ksbravo.sbhbase / RecetaFusion.cs
Game library
namespace SBH;

using Sandbox;
using System.Collections.Generic;

/// <summary>
/// [SBHBase] Receta de fusión: N ingredientes (por nombre visible en escena,
/// con cantidad) → una entidad resultado. Los ingredientes pueden ser
/// nextbots (su Print Name: "Raddy") o props (el nombre con el que spawnean
/// del menú Q) — la tool Merge no distingue. Las recetas son DATOS del pack:
/// cada pack trae sus archivos .fusion en Assets y la tool las levanta sola
/// de ResourceLibrary. El mecanismo vive en la base; los personajes, no.
/// </summary>
[AssetType( Name = "SBHBase Fusion Recipe", Extension = "fusion", Category = "SBHBase" )]
public class RecetaFusion : GameResource
{
	[Property, Description( "Qué hace falta juntar: nombre visible y cantidad de cada ingrediente" )]
	public List<IngredienteFusion> Ingredientes { get; set; } = new();

	[Property, Description( "Prefab que aparece al completar la receta" )]
	public PrefabFile Resultado { get; set; }

	[Property, Description( "Sonido al fusionar (opcional)" )]
	public SoundEvent Sonido { get; set; }
}

/// <summary> Un ingrediente de la receta: nombre visible + cuántos. </summary>
public class IngredienteFusion
{
	[Property, Description( "Identidad del ingrediente: Print Name del bot, o nombre del modelo del prop (\"watermelon\"); no distingue mayúsculas" )]
	public string Nombre { get; set; } = "";

	[Property, Description( "Cuántos hacen falta" )]
	public int Cantidad { get; set; } = 1;

	[Property, Description( "Ruta de asset para la miniatura del recetario (vacío = se busca el .sent por nombre; para props, la ruta del .vmdl)" )]
	public string Icono { get; set; }
}
ksbravo.sbhbase / SaltoNextbot.cs
Game library
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// [SBHBase] Salto de nextbot: cuando persigue y el objetivo está a otro
/// nivel — arriba de un contenedor, abajo de un puente — o el camino dejó
/// de avanzar, se lanza hacia él con una parábola balística. Sin miedo a
/// caer: si el objetivo está abajo, salta desde el borde directamente.
/// Durante el vuelo toma el control del movimiento (apaga NavMeshAgent y
/// NextbotBrain) y lo devuelve al aterrizar, reencajando el agente en el
/// navmesh. Usa el objetivo de PercepcionNextbot; sin ella no salta.
/// </summary>
public sealed class SaltoNextbot : Component
{
	[RequireComponent] NavMeshAgent Agent { get; set; }
	[RequireComponent] NextbotBrain Cerebro { get; set; }

	[Property, Description( "Desnivel mínimo con el objetivo para plantearse saltar" )]
	public float DesnivelMinimo { get; set; } = 80f;

	[Property, Description( "Distancia horizontal máxima que cubre un salto" )]
	public float DistanciaMaxima { get; set; } = 500f;

	[Property, Description( "Altura extra del arco por encima del punto más alto del salto" )]
	public float AlturaArco { get; set; } = 60f;

	[Property, Description( "Gravedad durante el vuelo" )]
	public float Gravedad { get; set; } = 800f;

	[Property, Description( "Segundos mínimos entre saltos" )]
	public float Cooldown { get; set; } = 2f;

	[Property, Description( "Si persiguiendo no avanza durante este tiempo (seg), salta hacia el objetivo aunque no haya desnivel" )]
	public float TiempoAtascado { get; set; } = 1.5f;

	[Property, Description( "Distancia de contacto para conectar el cabezazo en pleno vuelo (0 = sin golpe aéreo)" )]
	public float RangoGolpeAereo { get; set; } = 60f;

	[Property, Description( "Parámetro bool del animgraph para la anim de caída durante el vuelo (vacío = sin anim)" )]
	public string ParametroCaida { get; set; } = "b_fall";

	[Property, Description( "Velocidad vertical del salto CUANDO LO POSEE UN JUGADOR (la IA usa el arco balístico de arriba). 330 = salto de persona; un coloso necesita mucho más" )]
	public float FuerzaSaltoPosesion { get; set; } = 330f;

	// --- Multiplicador global en vivo (sbh.salto.mult) ---
	// Escala arco y alcance sin tocar los prefabs: se tunea jugando y después
	// se hornean los valores por personaje.
	float Mult => System.MathF.Max( SbhConVars.SaltoMultiplicador, 0.05f );
	float DistanciaEfectiva => DistanciaMaxima * Mult;
	float ArcoEfectivo => AlturaArco * Mult;

	/// <summary> La fuerza de salto para la posesión, ya con el multiplicador global. </summary>
	public float FuerzaSaltoPosesionEfectiva => FuerzaSaltoPosesion * Mult;

	/// <summary> ¿Está en el aire ahora mismo? (para que otros componentes lo consulten) </summary>
	public bool Volando { get; private set; }

	PercepcionNextbot _percepcion;
	Vida _vida;
	Vector3 _velocidad;
	TimeSince _desdeSalto;
	TimeSince _desdeDespegue;
	Vector3 _posicionAnterior;
	TimeSince _desdeAvance;

	protected override void OnStart()
	{
		_percepcion = Components.Get<PercepcionNextbot>();
		_vida = Components.Get<Vida>();
		_desdeSalto = 999f;
		_posicionAnterior = WorldPosition;
	}

	protected override void OnDisabled()
	{
		if ( Volando )
			Aterrizar( WorldPosition );
	}

	protected override void OnFixedUpdate()
	{
		if ( Volando )
		{
			Volar();
			return;
		}

		if ( _vida.IsValid() && _vida.EstaMuerto ) return;
		if ( _desdeSalto < Cooldown ) return;

		var destino = BuscarDestino();
		if ( destino == null )
		{
			_posicionAnterior = WorldPosition;
			_desdeAvance = 0f;
			return;
		}

		// Detección de atasco: ¿avanzamos algo desde la última vez?
		if ( WorldPosition.Distance( _posicionAnterior ) > 30f )
		{
			_posicionAnterior = WorldPosition;
			_desdeAvance = 0f;
		}

		var desnivel = destino.Value.z - WorldPosition.z;
		var distanciaH = (destino.Value - WorldPosition).WithZ( 0 ).Length;

		if ( distanciaH > DistanciaEfectiva ) return;

		var hayDesnivel = MathF.Abs( desnivel ) > DesnivelMinimo;
		var atascado = _desdeAvance > TiempoAtascado;

		if ( !hayDesnivel && !atascado ) return;
		if ( !hayDesnivel && distanciaH < 60f ) return; // ya está encima, no hace falta

		// ¿El arco CABE? Con el objetivo arriba de un techo/losa, el bot de
		// abajo saltaba una y otra vez contra el cielorraso queriendo
		// traspasarlo. Si algo del mundo tapa la vertical hasta el ápice del
		// salto, no hay salto — se sigue por navmesh como cualquier hijo de
		// vecino (y el próximo intento será desde otro lugar).
		var apice = MathF.Max( WorldPosition.z, destino.Value.z ) + ArcoEfectivo;
		var alturaVuelo = apice - WorldPosition.z;
		if ( alturaVuelo > 24f )
		{
			var techo = Scene.Trace
				.Ray( WorldPosition + Vector3.Up * 24f, WorldPosition + Vector3.Up * alturaVuelo )
				.Radius( 14f )
				.IgnoreGameObjectHierarchy( GameObject )
				.WithoutTags( "sbh_nextbot", "player" )
				.Run();

			if ( techo.Hit ) return; // hay techo: el salto no cabe
		}

		Saltar( destino.Value );
	}

	/// <summary> Hacia dónde saltar, o null si no hay persecución activa. </summary>
	Vector3? BuscarDestino()
	{
		if ( !_percepcion.IsValid() || !_percepcion.Enabled ) return null;

		var objetivo = _percepcion.ObjetivoActual;
		if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return null;

		return _percepcion.ObjetivoVisible
			? objetivo.WorldPosition
			: _percepcion.UltimaPosicionConocida;
	}

	void Saltar( Vector3 destino )
	{
		var origen = WorldPosition;

		// Balística: subir hasta AlturaArco por encima del punto más alto y
		// caer justo en el destino. vz por energía, tiempo por cinemática.
		var apex = MathF.Max( 0f, destino.z - origen.z ) + ArcoEfectivo;
		var vz = MathF.Sqrt( 2f * Gravedad * apex );

		var raiz = vz * vz - 2f * Gravedad * (destino.z - origen.z);
		if ( raiz < 0f ) return;

		var tiempo = (vz + MathF.Sqrt( raiz )) / Gravedad;
		if ( tiempo < 0.1f ) return;

		var horizontal = (destino - origen).WithZ( 0 );
		_velocidad = horizontal / tiempo + Vector3.Up * vz;

		if ( horizontal.Length > 1f )
			WorldRotation = Rotation.LookAt( horizontal.Normal, Vector3.Up );

		Agent.Enabled = false;
		Cerebro.Enabled = false;

		// En el aire: anim de caída (la misma que usa la physgun)
		if ( !string.IsNullOrEmpty( ParametroCaida ) )
			Components.Get<SkinnedModelRenderer>()?.Set( ParametroCaida, true );

		Volando = true;
		_desdeDespegue = 0f;
		_desdeSalto = 0f;
	}

	void Volar()
	{
		var dt = Time.Delta;
		_velocidad += Vector3.Down * Gravedad * dt;

		// Avanzar con una esfera a la altura del torso para chocar con el mundo
		var desde = WorldPosition + Vector3.Up * 24f;
		var hasta = desde + _velocidad * dt;

		var trace = Scene.Trace.Ray( desde, hasta )
			.Radius( 14f )
			.IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "sbh_nextbot" ) // otro nextbot no es piso: atravesarlo, no aterrizar encima
			.Run();

		if ( trace.Hit )
		{
			// Piso (o algo pisable) mientras cae: aterrizar
			if ( _velocidad.z <= 0f || trace.Normal.z > 0.5f )
			{
				Aterrizar( trace.EndPosition - Vector3.Up * 24f );
				return;
			}

			// Pared en plena subida: cortar el avance y dejar que caiga
			_velocidad = _velocidad.WithX( 0f ).WithY( 0f );
			WorldPosition = trace.EndPosition - Vector3.Up * 24f;
		}
		else
		{
			WorldPosition = hasta - Vector3.Up * 24f;
		}

		// Cabezazo en vuelo: si el arco pasa por encima del objetivo, conecta
		IntentarGolpeAereo();

		// Seguridad: nunca volar para siempre (agujeros, caídas al vacío)
		if ( _desdeDespegue > 4f )
			Aterrizar( WorldPosition );
	}

	/// <summary>
	/// El pounce: en pleno vuelo, si el objetivo queda a distancia de cuerpo,
	/// el golpe conecta ahí mismo — sin retardo, el contacto ES el impacto.
	/// El cooldown de ataque del cerebro evita golpear dos veces por salto.
	/// </summary>
	void IntentarGolpeAereo()
	{
		if ( RangoGolpeAereo <= 0f || !Cerebro.IsValid() ) return;

		var objetivo = _percepcion.IsValid() ? _percepcion.ObjetivoActual : null;
		if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;

		// Medir desde el torso en vuelo contra el torso del objetivo
		var torso = WorldPosition + Vector3.Up * 24f;
		var torsoObjetivo = objetivo.WorldPosition + Vector3.Up * 32f;

		if ( torso.Distance( torsoObjetivo ) > RangoGolpeAereo ) return;

		Cerebro.GolpeDirecto( objetivo );
	}

	void Aterrizar( Vector3 posicion )
	{
		Volando = false;
		WorldPosition = posicion;

		if ( !string.IsNullOrEmpty( ParametroCaida ) )
			Components.Get<SkinnedModelRenderer>()?.Set( ParametroCaida, false );

		// Si murió en el aire, MuerteNextbot ya apagó la IA: no revivirla
		if ( _vida.IsValid() && _vida.EstaMuerto ) return;

		// Pisada de gigante: el aterrizaje retumba (más fuerte que el paso)
		Components.Get<PisadaGigante>()?.AlAterrizar();

		if ( Agent.IsValid() )
		{
			Agent.Enabled = true;
			Agent.SetAgentPosition( posicion );
		}

		if ( Cerebro.IsValid() )
			Cerebro.Enabled = true;
	}
}
ksbravo.sbhbase / CabezaNextbot.cs
Game library
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// [SBHBase] La cabeza del nextbot sigue al objetivo, estilo DRGBase.
/// Alimenta el nodo Look At del animgraph compartido (sbh_nextbot.vanmgrph)
/// vía <see cref="SkinnedModelRenderer.SetLookDirection"/>: escribe el
/// parámetro vector (dirección de mirada) y su peso, y el engine gira la
/// cadena de huesos LookChain del vmdl ENCIMA de la animación viva — la
/// cabeza conserva el rebote de correr mientras mira al objetivo, sin
/// bone-overrides ni anclas (el enfoque anterior peleaba con el snapshot
/// de huesos del engine y quedó descartado).
/// Es el equivalente del autoplay blend del QC de GMod.
/// Usa el objetivo de PercepcionNextbot; sin ese componente no hace nada.
/// Requiere que el modelo tenga animgraph con el parámetro y una LookChain;
/// si no los tiene, SetLookDirection simplemente no hace nada (inofensivo).
/// </summary>
public sealed class CabezaNextbot : Component
{
	[RequireComponent] SkinnedModelRenderer Renderer { get; set; }

	[Property, Description( "Giro horizontal máximo hacia cada lado, en grados" )]
	public float LimiteYaw { get; set; } = 70f;

	[Property, Description( "Inclinación vertical máxima (arriba/abajo), en grados" )]
	public float LimitePitch { get; set; } = 35f;

	[Property, Description( "Velocidad de seguimiento (más alto = más brusco)" )]
	public float Velocidad { get; set; } = 8f;

	[Property, Description( "Altura sobre el origen del objetivo a la que mira (sus ojos)" )]
	public float AlturaMirada { get; set; } = 55f;

	[Property, Description( "Nombre del parámetro vector de mirada en el animgraph (el peso va en <nombre>_weight)" )]
	public string ParametroMirada { get; set; } = "aim_head";

	[Property, Description( "Calibración fina: grados que se suman al yaw calculado (positivo = más a la izquierda). Para corregir desvíos si el frame del modelo no quedó perfecto" )]
	public float DesvioYaw { get; set; } = 0f;

	[Property, Description( "Calibración fina: grados que se suman al pitch calculado (positivo = más abajo)" )]
	public float DesvioPitch { get; set; } = 0f;

	PercepcionNextbot _percepcion;
	Vida _vida;
	Vector3 _direccion;   // dirección de mirada suavizada, en espacio de mundo
	float _peso;          // 0 = la animación manda, 1 = mirada plena
	string _personaje;    // para el interruptor SinCabeza de EventosAnim

	protected override void OnStart()
	{
		_percepcion = Components.Get<PercepcionNextbot>();
		_vida = Components.Get<Vida>();
		_direccion = WorldRotation.Forward;
		_personaje = EventosAnim.PersonajeDe( Renderer );
	}

	protected override void OnDisabled()
	{
		// Devolverle la cabeza a la animación (muerte → ragdoll, IA apagada...)
		_peso = 0f;
		_direccion = WorldRotation.Forward;

		if ( Renderer.IsValid() )
			Renderer.SetLookDirection( ParametroMirada, _direccion, 0f );
	}

	protected override void OnUpdate()
	{
		if ( !Renderer.IsValid() ) return;

		var puntoMirada = BuscarPuntoMirada();

		var dirDeseada = WorldRotation.Forward;
		var pesoDeseado = 0f;

		if ( puntoMirada != null )
		{
			var origen = WorldPosition + Vector3.Up * AlturaMirada;
			var (yaw, pitch) = CalcularAngulos( puntoMirada.Value, origen );

			// Demasiado a la espalda: soltar la mirada en vez de retorcer el cuello
			if ( yaw != null )
			{
				// Reconstruir la dirección ya clampeada a los límites del cuello,
				// con la calibración fina encima (corrige frames imperfectos)
				dirDeseada = WorldRotation * Rotation.From( pitch.Value + DesvioPitch, yaw.Value + DesvioYaw, 0f ) * Vector3.Forward;
				pesoDeseado = 1f;
			}
		}

		// Suavizado independiente del framerate; el nodo del animgraph trae su
		// propio amortiguador (spring), esto solo gobierna entrar/salir de mirada
		var t = 1f - MathF.Exp( -Velocidad * Time.Delta );
		_direccion = Vector3.Slerp( _direccion, dirDeseada, t ).Normal;
		_peso = _peso.LerpTo( pesoDeseado, t );

		Renderer.SetLookDirection( ParametroMirada, _direccion, _peso );
	}

	/// <summary> Dónde mirar, o null si no hay nada que mirar (o estamos muertos). </summary>
	Vector3? BuscarPuntoMirada()
	{
		// Interruptor por personaje (2026-08-10): rigs GMod donde "head" es
		// una parte especial (tapa del libro de Buuik, tapa del pastel de
		// Cake) — el peso baja a 0 con el suavizado de siempre
		if ( EventosAnim.CabezaApagada( _personaje ) ) return null;

		if ( _vida.IsValid() && _vida.EstaMuerto ) return null;
		if ( !_percepcion.IsValid() || !_percepcion.Enabled ) return null;

		var objetivo = _percepcion.ObjetivoActual;
		if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return null;

		var punto = _percepcion.ObjetivoVisible
			? objetivo.WorldPosition
			: _percepcion.UltimaPosicionConocida;

		return punto + Vector3.Up * AlturaMirada;
	}

	/// <summary>
	/// Ángulos hacia el punto en espacio del cuerpo, ya clampeados a los
	/// límites, o (null, null) si el objetivo queda demasiado a la espalda.
	/// </summary>
	(float? yaw, float? pitch) CalcularAngulos( Vector3 puntoMirada, Vector3 origen )
	{
		var dirLocal = WorldRotation.Inverse * (puntoMirada - origen).Normal;

		var yaw = MathF.Atan2( dirLocal.y, dirLocal.x ).RadianToDegree();
		var pitch = -MathF.Asin( dirLocal.z.Clamp( -1f, 1f ) ).RadianToDegree();

		if ( MathF.Abs( yaw ) > 120f ) return (null, null);

		return (yaw.Clamp( -LimiteYaw, LimiteYaw ), pitch.Clamp( -LimitePitch, LimitePitch ));
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 73,
    "Files": [
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/HuesosManipulados.cs",
            "FileName": "HuesosManipulados.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Collections.Generic;\n\n/// <summary>\n/// [SBHBase] Huesos manipulados a mano (los ManipulateBone* de GMod, para el\n/// Advanced Bone Tool): guarda un ajuste por hueso (\u00e1ngulos/posici\u00f3n/escala,\n/// offsets en el MARCO LOCAL del hueso) y lo aplica cada frame POR ENCIMA de\n/// la animaci\u00f3n ya evaluada \u2014 patr\u00f3n BaileProcedural: TryGetBoneTransformAnimation\n/// lee la pose SIN overrides (sin retroalimentaci\u00f3n) y SetBoneOverride pisa.\n/// Con la lista vac\u00eda no toca nada (la animaci\u00f3n queda pura). En un bot\n/// muerto (tag sbh_muerto) se apaga: el ragdoll manda.\n/// OJO: comparte SceneModel.ClearBoneOverrides con otros drivers de huesos\n/// (ej. BaileProcedural) \u2014 dos drivers vivos en el mismo bot se pisan entre s\u00ed.\n/// </summary>\npublic sealed class HuesosManipulados : Component\n{\n\tpublic struct Ajuste\n\t{\n\t\tpublic Angles Angulos;\n\t\tpublic Vector3 Posicion;\n\t\tpublic float Escala;\n\n\t\tpublic static readonly Ajuste Identidad = new() { Escala = 1f };\n\n\t\tpublic bool EsIdentidad =>\n\t\t\tAngulos == Angles.Zero && Posicion == Vector3.Zero && Escala == 1f;\n\t}\n\n\treadonly Dictionary<string, Ajuste> _ajustes = new();\n\tSkinnedModelRenderer _renderer;\n\tbool _aplicado;\n\n\tpublic IReadOnlyDictionary<string, Ajuste> Ajustes => _ajustes;\n\n\tpublic Ajuste De( string hueso )\n\t\t=> hueso != null && _ajustes.TryGetValue( hueso, out var a ) ? a : Ajuste.Identidad;\n\n\tpublic void Fijar( string hueso, Ajuste ajuste )\n\t{\n\t\tif ( string.IsNullOrEmpty( hueso ) ) return;\n\n\t\tif ( ajuste.EsIdentidad )\n\t\t\t_ajustes.Remove( hueso );\n\t\telse\n\t\t\t_ajustes[hueso] = ajuste;\n\t}\n\n\tpublic void Reiniciar() => _ajustes.Clear();\n\n\tprotected override void OnEnabled()\n\t{\n\t\t_renderer = Components.Get<SkinnedModelRenderer>();\n\t}\n\n\tprotected override void OnDisabled() => Limpiar();\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( !_renderer.IsValid() || _renderer.SceneModel == null ) return;\n\n\t\tif ( _ajustes.Count == 0 || GameObject.Tags.Has( \"sbh_muerto\" ) )\n\t\t{\n\t\t\tLimpiar();\n\t\t\treturn;\n\t\t}\n\n\t\tvar sm = _renderer.SceneModel;\n\t\tsm.ClearBoneOverrides();\n\n\t\tforeach ( var (nombre, ajuste) in _ajustes )\n\t\t{\n\t\t\tvar hueso = _renderer.Model?.Bones?.GetBone( nombre );\n\t\t\tif ( hueso == null ) continue;\n\n\t\t\t// Pose de la ANIMACI\u00d3N (mundo), sin nuestros overrides\n\t\t\tif ( !_renderer.TryGetBoneTransformAnimation( hueso, out var mundo ) ) continue;\n\n\t\t\tvar local = sm.Transform.ToLocal( mundo );\n\n\t\t\t// Offsets en el marco local del hueso (como ManipulateBone* de GMod)\n\t\t\tlocal.Position += local.Rotation * ajuste.Posicion;\n\t\t\tlocal.Rotation *= Rotation.From( ajuste.Angulos );\n\t\t\tlocal.Scale *= ajuste.Escala;\n\n\t\t\tsm.SetBoneOverride( hueso.Index, local );\n\t\t}\n\n\t\t_aplicado = true;\n\t}\n\n\tvoid Limpiar()\n\t{\n\t\tif ( !_aplicado ) return;\n\t\t_aplicado = false;\n\n\t\tif ( _renderer.IsValid() && _renderer.SceneModel != null )\n\t\t\t_renderer.SceneModel.ClearBoneOverrides();\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/LibroFusiones.cs",
            "FileName": "LibroFusiones.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\n/// <summary>\n/// [SBHBase] El libro de recetas de fusi\u00f3n. Junta DOS fuentes:\n///  - Las recetas de PACK: assets .fusion (RecetaFusion), solo lectura.\n///  - Las recetas PROPIAS: creadas en el juego con la tool Merge en modo\n///    crear; persisten como JSON en FileSystem.Data y sobreviven sesiones.\n/// La tool y el recetario consumen Todas() sin distinguir origen (salvo\n/// para permitir borrar: solo las propias).\n/// </summary>\npublic static class LibroFusiones\n{\n\tconst string Archivo = \"sbh_fusiones.json\";\n\n\t/// <summary> Una receta lista para usar, venga del pack o del juego. </summary>\n\tpublic class Receta\n\t{\n\t\tpublic List<IngredienteFusion> Ingredientes { get; set; } = new();\n\n\t\t/// <summary> Ruta del prefab que aparece al fusionar. </summary>\n\t\tpublic string RutaResultado { get; set; } = \"\";\n\n\t\t/// <summary> Ruta de un SoundEvent opcional. </summary>\n\t\tpublic string RutaSonido { get; set; }\n\n\t\t/// <summary> Vino de un asset .fusion: no se puede borrar desde el juego. </summary>\n\t\tpublic bool DePack { get; set; }\n\t}\n\n\tstatic List<Receta> _propias;\n\n\t/// <summary> Sube con cada alta/baja de receta propia \u2014 para BuildHash de la UI. </summary>\n\tpublic static int Version { get; private set; }\n\n\t/// <summary> Todas las recetas v\u00e1lidas: primero las del pack, despu\u00e9s las propias. </summary>\n\tpublic static IEnumerable<Receta> Todas()\n\t{\n\t\tforeach ( var r in ResourceLibrary.GetAll<RecetaFusion>() )\n\t\t{\n\t\t\tif ( r.Resultado == null ) continue;\n\t\t\tif ( r.Ingredientes == null || r.Ingredientes.Count == 0 ) continue;\n\n\t\t\tyield return new Receta\n\t\t\t{\n\t\t\t\tIngredientes = r.Ingredientes,\n\t\t\t\tRutaResultado = r.Resultado.ResourcePath,\n\t\t\t\tRutaSonido = r.Sonido?.ResourcePath,\n\t\t\t\tDePack = true,\n\t\t\t};\n\t\t}\n\n\t\tforeach ( var r in Propias() )\n\t\t\tyield return r;\n\t}\n\n\tstatic List<Receta> Propias()\n\t{\n\t\tif ( _propias != null ) return _propias;\n\n\t\t_propias = new List<Receta>();\n\n\t\ttry\n\t\t{\n\t\t\tif ( FileSystem.Data.FileExists( Archivo ) )\n\t\t\t\t_propias = Json.Deserialize<List<Receta>>( FileSystem.Data.ReadAllText( Archivo ) ) ?? new();\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"LibroFusiones: no pude leer {Archivo} \u2014 {e.Message}\" );\n\t\t}\n\n\t\tforeach ( var r in _propias )\n\t\t\tr.DePack = false;\n\n\t\treturn _propias;\n\t}\n\n\t/// <summary>\n\t/// Guarda una receta creada en el juego. Si ya hab\u00eda una propia con los\n\t/// MISMOS ingredientes, se reemplaza \u2014 re-grabar una receta es la forma\n\t/// de cambiarle el resultado.\n\t/// </summary>\n\tpublic static void Guardar( Receta receta )\n\t{\n\t\tvar propias = Propias();\n\t\tpropias.RemoveAll( r => MismosIngredientes( r, receta ) );\n\t\tpropias.Add( receta );\n\t\tPersistir();\n\t}\n\n\t/// <summary> Borra una receta propia (las de pack no se tocan). </summary>\n\tpublic static void Borrar( Receta receta )\n\t{\n\t\tif ( receta.DePack ) return;\n\n\t\tPropias().RemoveAll( r => MismosIngredientes( r, receta )\n\t\t\t&& r.RutaResultado == receta.RutaResultado );\n\t\tPersistir();\n\t}\n\n\t/// <summary> El bot\u00f3n nuclear: borra TODAS las recetas propias de un saque. </summary>\n\tpublic static void BorrarTodas()\n\t{\n\t\tPropias().Clear();\n\t\tPersistir();\n\t}\n\n\tstatic void Persistir()\n\t{\n\t\ttry\n\t\t{\n\t\t\tFileSystem.Data.WriteAllText( Archivo, Json.Serialize( _propias ) );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"LibroFusiones: no pude guardar {Archivo} \u2014 {e.Message}\" );\n\t\t}\n\n\t\tVersion++;\n\t}\n\n\t// Mismo multiset de ingredientes (nombres sin may\u00fasculas, cantidades sumadas)\n\tstatic bool MismosIngredientes( Receta a, Receta b )\n\t{\n\t\tstatic Dictionary<string, int> Mapa( Receta r ) => r.Ingredientes\n\t\t\t.GroupBy( i => i.Nombre?.Trim().ToLowerInvariant() ?? \"\" )\n\t\t\t.ToDictionary( g => g.Key, g => g.Sum( i => Math.Max( 1, i.Cantidad ) ) );\n\n\t\tvar ma = Mapa( a );\n\t\tvar mb = Mapa( b );\n\n\t\treturn ma.Count == mb.Count\n\t\t\t&& ma.All( kv => mb.TryGetValue( kv.Key, out var c ) && c == kv.Value );\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/RitmoInyector.cs",
            "FileName": "RitmoInyector.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Collections.Generic;\n\n/// <summary>\n/// [SBHBase] Inyecta el RitmoPersonaje configurado a todo nextbot sprunki\n/// que aparezca en escena (tag \"sbh_nextbot\"), identificando al personaje\n/// por el nombre de su vmdl. Mismo patr\u00f3n que SbhPuente: las integraciones\n/// se inyectan en runtime y los prefabs de la librer\u00eda base quedan limpios\n/// \u2014 SBHBase ni se entera de que existe el sistema de ritmos.\n/// El cat\u00e1logo cubre los 20 personajes can\u00f3nicos del juego original (ver\n/// MAPA_PERSONAJES.md en Work S&BOX\\Pack-01-Sprunkis\\00-audios-ritmos).\n/// </summary>\npublic sealed class RitmoInyector : GameObjectSystem\n{\n\tsealed record Loops( string NormalA, string NormalB, string HorrorA, string HorrorB );\n\n\t// clave = fragmento del nombre del vmdl \u00b7 rutas relativas a sounds/ritmos/\n\tstatic readonly Dictionary<string, Loops> Catalogo = new()\n\t{\n\t\t// Los tres ya modelados (con sus alias actuales orng/grey/red)\n\t\t[\"sprunki_oren\"] = new( \"orange_oren/kick\", null, \"orange_oren/kick3\", null ),\n\t\t[\"sprunki_orng\"] = new( \"orange_oren/kick\", null, \"orange_oren/kick3\", null ),\n\t\t[\"sprunki_raddy\"] = new( \"red_raddy/snare\", null, \"red_raddy/snare3\", null ),\n\t\t[\"sprunki_red\"] = new( \"red_raddy/snare\", null, \"red_raddy/snare3\", null ),\n\t\t[\"sprunki_gray\"] = new( \"gray_gray/bass1\", \"gray_gray/bass2\", \"gray_gray/bass3\", \"gray_gray/bass4\" ),\n\t\t[\"sprunki_grey\"] = new( \"gray_gray/bass1\", \"gray_gray/bass2\", \"gray_gray/bass3\", \"gray_gray/bass4\" ),\n\n\t\t// El resto del elenco can\u00f3nico, listos para cuando existan los modelos\n\t\t[\"sprunki_clukr\"] = new( \"silver_clukr/ride_cymbal\", \"silver_clukr/ride_cymbal2\", \"silver_clukr/knock_knocn\", \"silver_clukr/knock_knocn2\" ),\n\t\t[\"sprunki_funbot\"] = new( \"fun_bot/amen\", null, \"fun_bot/amen3\", null ),\n\t\t[\"sprunki_vineria\"] = new( \"green_vineria/shaky_shaky\", null, \"green_vineria/shakey\", null ),\n\t\t[\"sprunki_brud\"] = new( \"brown_brud/goofy_ahh_thing\", null, \"brown_brud/weird_sound\", \"brown_brud/weird_sound2\" ),\n\t\t[\"sprunki_garnold\"] = new( \"gold_garnold/arpeg\", null, \"gold_garnold/slow_arpeg\", null ),\n\t\t[\"sprunki_owakcx\"] = new( \"lime_owakcx/wind_up1\", \"lime_owakcx/wind_up2\", \"lime_owakcx/better_windup\", null ),\n\t\t[\"sprunki_sky\"] = new( \"sky_blue_sky/music_box1\", \"sky_blue_sky/music_box2\", \"sky_blue_sky/music1\", \"sky_blue_sky/music2\" ),\n\t\t[\"sprunki_mrsun\"] = new( \"mr_sun/piano1\", \"mr_sun/piano2\", \"mr_sun/piano_new1\", \"mr_sun/piano_new2\" ),\n\t\t[\"sprunki_durple\"] = new( \"purple_durple/tromp_new1\", \"purple_durple/tromp_new2\", \"purple_durple/horns1\", \"purple_durple/horns2\" ),\n\t\t[\"sprunki_mrtree\"] = new( \"mr_tree/organ1\", \"mr_tree/organ2\", \"mr_tree/slow_organ1\", \"mr_tree/slow_organ2\" ),\n\t\t[\"sprunki_simon\"] = new( \"yellow_simon/square1\", \"yellow_simon/square2\", \"yellow_simon/square_new1\", \"yellow_simon/square_new2\" ),\n\t\t[\"sprunki_tunner\"] = new( \"tan_tunner/whistle1\", \"tan_tunner/whistle2\", \"tan_tunner/whistle3\", \"tan_tunner/whistle4\" ),\n\t\t[\"sprunki_mrfuncomputer\"] = new( \"mr_fun_computer/aising1\", \"mr_fun_computer/aising2\", \"mr_fun_computer/aispeak1\", \"mr_fun_computer/aispeak2\" ),\n\t\t[\"sprunki_wenda\"] = new( \"white_wenda/boring_hey_sound\", null, \"white_wenda/crazy\", null ),\n\t\t[\"sprunki_pinki\"] = new( \"pink_pinki/femchoir1\", \"pink_pinki/femchoir2\", \"pink_pinki/choir1\", \"pink_pinki/choir2\" ),\n\t\t[\"sprunki_jevin\"] = new( \"blue_jevin/new_choir1\", \"blue_jevin/new_choir2\", \"blue_jevin/male_choir\", \"blue_jevin/male_choir2\" ),\n\n\t\t// El secreto: mudo en fase normal, solo existe en horror\n\t\t[\"sprunki_black\"] = new( null, null, \"blackx/creepy1\", \"blackx/creepy2\" ),\n\n\t\t// El Basic (polo en blanco, sin personaje): MUDO por dise\u00f1o en ambas\n\t\t// fases \u2014 la entrada existe para declararlo y que el inyector no avise\n\t\t[\"sprunki_base\"] = new( null, null, null, null ),\n\t\t[\"sprunki_hrr_base\"] = new( null, null, null, null ),\n\n\t\t// ---- Personajes HORROR (sprunki_hrr_*): personajes COMPLETOS aparte,\n\t\t// su loop \"normal\" ES el sonido horror \u2014 cantan horror en toda fase\n\t\t// (como Black en el original). El prefijo hrr evita colisiones con el\n\t\t// Contains de los nombres base.\n\t\t[\"sprunki_hrr_oren\"] = new( \"orange_oren/kick3\", null, \"orange_oren/kick3\", null ),\n\t\t[\"sprunki_hrr_raddy\"] = new( \"red_raddy/snare3\", null, \"red_raddy/snare3\", null ),\n\t\t[\"sprunki_hrr_gray\"] = new( \"gray_gray/bass3\", \"gray_gray/bass4\", \"gray_gray/bass3\", \"gray_gray/bass4\" ),\n\t\t[\"sprunki_hrr_clukr\"] = new( \"silver_clukr/knock_knocn\", \"silver_clukr/knock_knocn2\", \"silver_clukr/knock_knocn\", \"silver_clukr/knock_knocn2\" ),\n\t\t[\"sprunki_hrr_funbot\"] = new( \"fun_bot/amen3\", null, \"fun_bot/amen3\", null ),\n\t\t[\"sprunki_hrr_vineria\"] = new( \"green_vineria/shakey\", null, \"green_vineria/shakey\", null ),\n\t\t[\"sprunki_hrr_brud\"] = new( \"brown_brud/weird_sound\", \"brown_brud/weird_sound2\", \"brown_brud/weird_sound\", \"brown_brud/weird_sound2\" ),\n\t\t[\"sprunki_hrr_garnold\"] = new( \"gold_garnold/slow_arpeg\", null, \"gold_garnold/slow_arpeg\", null ),\n\t\t[\"sprunki_hrr_owakcx\"] = new( \"lime_owakcx/better_windup\", null, \"lime_owakcx/better_windup\", null ),\n\t\t[\"sprunki_hrr_sky\"] = new( \"sky_blue_sky/music1\", \"sky_blue_sky/music2\", \"sky_blue_sky/music1\", \"sky_blue_sky/music2\" ),\n\t\t[\"sprunki_hrr_mrsun\"] = new( \"mr_sun/piano_new1\", \"mr_sun/piano_new2\", \"mr_sun/piano_new1\", \"mr_sun/piano_new2\" ),\n\t\t[\"sprunki_hrr_durple\"] = new( \"purple_durple/horns1\", \"purple_durple/horns2\", \"purple_durple/horns1\", \"purple_durple/horns2\" ),\n\t\t[\"sprunki_hrr_mrtree\"] = new( \"mr_tree/slow_organ1\", \"mr_tree/slow_organ2\", \"mr_tree/slow_organ1\", \"mr_tree/slow_organ2\" ),\n\t\t[\"sprunki_hrr_simon\"] = new( \"yellow_simon/square_new1\", \"yellow_simon/square_new2\", \"yellow_simon/square_new1\", \"yellow_simon/square_new2\" ),\n\t\t[\"sprunki_hrr_tunner\"] = new( \"tan_tunner/whistle3\", \"tan_tunner/whistle4\", \"tan_tunner/whistle3\", \"tan_tunner/whistle4\" ),\n\t\t[\"sprunki_hrr_mrfuncomputer\"] = new( \"mr_fun_computer/aispeak1\", \"mr_fun_computer/aispeak2\", \"mr_fun_computer/aispeak1\", \"mr_fun_computer/aispeak2\" ),\n\t\t[\"sprunki_hrr_wenda\"] = new( \"white_wenda/crazy\", null, \"white_wenda/crazy\", null ),\n\t\t[\"sprunki_hrr_pinki\"] = new( \"pink_pinki/choir1\", \"pink_pinki/choir2\", \"pink_pinki/choir1\", \"pink_pinki/choir2\" ),\n\t\t[\"sprunki_hrr_jevin\"] = new( \"blue_jevin/male_choir\", \"blue_jevin/male_choir2\", \"blue_jevin/male_choir\", \"blue_jevin/male_choir2\" ),\n\t\t[\"sprunki_hrr_black\"] = new( \"blackx/creepy1\", \"blackx/creepy2\", \"blackx/creepy1\", \"blackx/creepy2\" ),\n\t};\n\n\tTimeSince _desdeEscaneo;\n\treadonly HashSet<string> _sinCatalogo = new(); // para avisar una sola vez por modelo\n\n\tpublic RitmoInyector( Scene scene ) : base( scene )\n\t{\n\t\tListen( Stage.StartUpdate, 20, Escanear, \"RitmoInyectorSprunki\" );\n\t}\n\n\tvoid Escanear()\n\t{\n\t\tif ( _desdeEscaneo < 1f ) return;\n\t\t_desdeEscaneo = 0f;\n\n\t\tforeach ( var renderer in Scene.GetAllComponents<SkinnedModelRenderer>() )\n\t\t{\n\t\t\tvar go = renderer.GameObject;\n\t\t\tif ( !go.IsValid() || !go.Tags.Has( \"sbh_nextbot\" ) ) continue;\n\t\t\t// Accesorios bonemerge (Easy Bonemerge de SBHBase): heredan el tag\n\t\t\t// del bot que visten, pero son decoraci\u00f3n \u2014 no cantan\n\t\t\tif ( go.Tags.Has( \"sbh_accesorio\" ) ) continue;\n\t\t\tif ( go.Components.Get<RitmoPersonaje>( true ) != null ) continue;\n\n\t\t\tvar modelo = renderer.Model?.ResourcePath ?? \"\";\n\t\t\tif ( modelo == \"\" ) continue;\n\n\t\t\tvar enganchado = false;\n\n\t\t\tforeach ( var (clave, loops) in Catalogo )\n\t\t\t{\n\t\t\t\tif ( !modelo.Contains( clave ) ) continue;\n\t\t\t\tenganchado = true;\n\n\t\t\t\tvar ritmo = go.Components.Create<RitmoPersonaje>();\n\t\t\t\tritmo.LoopNormalA = Cargar( loops.NormalA );\n\t\t\t\tritmo.LoopNormalB = Cargar( loops.NormalB );\n\t\t\t\tritmo.LoopHorrorA = Cargar( loops.HorrorA );\n\t\t\t\tritmo.LoopHorrorB = Cargar( loops.HorrorB );\n\n\t\t\t\t// El Clukr (base y horror) trae su bailecito procedural: la tapa\n\t\t\t\t// rebota y los ojos giran mientras suena su loop\n\t\t\t\tif ( clave.Contains( \"clukr\" ) && go.Components.Get<BaileProcedural>( true ) == null )\n\t\t\t\t\tgo.Components.Create<BaileProcedural>();\n\n\t\t\t\tLog.Info( $\"\ud83c\udfb5 RitmoPersonaje inyectado a {go.Name} ({clave})\" );\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\t// Nextbot sprunki sin ritmo en el cat\u00e1logo: avisar una sola vez \u2014\n\t\t\t// casi siempre es un nombre de vmdl que no respeta el sprunki_<personaje>.\n\t\t\t// Bots de OTROS packs (trevorhenderson, bfdi...) no son asunto de\n\t\t\t// este inyector: silencio total (multi-pack desde 2026-07-26)\n\t\t\tif ( !enganchado && modelo.Contains( \"sprunki\" ) && _sinCatalogo.Add( modelo ) )\n\t\t\t\tLog.Warning( $\"RitmoInyector: '{modelo}' no matchea ning\u00fan personaje del cat\u00e1logo (el vmdl debe llamarse sprunki_<nombre can\u00f3nico>, ver MAPA_PERSONAJES.md)\" );\n\t\t}\n\t}\n\n\tstatic SoundEvent Cargar( string ruta )\n\t{\n\t\tif ( string.IsNullOrEmpty( ruta ) ) return null;\n\n\t\tvar sonido = ResourceLibrary.Get<SoundEvent>( $\"sounds/ritmos/{ruta}.sound\" );\n\t\tif ( sonido == null )\n\t\t\tLog.Warning( $\"RitmoInyector: no existe sounds/ritmos/{ruta}.sound\" );\n\n\t\treturn sonido;\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "DirectorRitmo.cs",
            "FileName": "DirectorRitmo.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System;\n\n/// <summary>\n/// [SBHBase] El reloj del juego original, calcado del \"loop controller\"\n/// del Sprunki de Scratch: un pulso cada 4.8 segundos (100 BPM, 2 compases\n/// de 4/4) alternando \"Loop 1\" y \"Loop 2\". Los personajes RE-DISPARAN su\n/// sonido en cada pulso \u2014 la sincron\u00eda es por retrigger, no hay loops\n/// continuos que mantener en fase.\n/// Cambio de fase (horror): corta todo, espera 2s y rearranca el reloj en\n/// el pulso 0, igual que el broadcast \"Phase 2\" del original.\n/// Es un GameObjectSystem: existe solo en toda escena, sin setup.\n/// </summary>\npublic sealed class DirectorRitmo : GameObjectSystem\n{\n\t/// <summary> Segundos entre pulsos: la variable Delay = 4.8 del original. </summary>\n\tpublic const float DuracionPulso = 4.8f;\n\n\t/// <summary> Silencio al cambiar de fase, como el wait 2 del original. </summary>\n\tpublic const float SilencioCambioFase = 2f;\n\n\t/// <summary> N\u00famero de pulso actual (0, 1, 2...); par = \"Loop 1\", impar = \"Loop 2\". -1 = todav\u00eda no arranc\u00f3. </summary>\n\tpublic int NumeroPulso { get; private set; } = -1;\n\n\t/// <summary> Fase horror activa (espejo estable de RitmoConVars.FaseHorror). </summary>\n\tpublic bool FaseHorror { get; private set; }\n\n\t/// <summary> Se dispara en cada pulso con su n\u00famero. Los RitmoPersonaje se suscriben ac\u00e1. </summary>\n\tpublic event Action<int> AlPulso;\n\n\tfloat _proximoPulso;\n\tfloat _silencioHasta = -1f;\n\n\tpublic DirectorRitmo( Scene scene ) : base( scene )\n\t{\n\t\tListen( Stage.StartUpdate, 10, Latir, \"DirectorRitmoPersonaje\" );\n\t\t_proximoPulso = Time.Now;\n\t}\n\n\t/// <summary> Avance 0..1 dentro del pulso actual (para sincronizar bailes). </summary>\n\tpublic float FraccionPulso => 1f - ((_proximoPulso - Time.Now) / DuracionPulso).Clamp( 0f, 1f );\n\n\tvoid Latir()\n\t{\n\t\t// Apagado: reloj detenido; al reactivar arranca limpio desde el pulso 0\n\t\tif ( !RitmoConVars.RitmoActivado )\n\t\t{\n\t\t\tNumeroPulso = -1;\n\t\t\t_proximoPulso = Time.Now;\n\t\t\treturn;\n\t\t}\n\n\t\t// Cambio de fase estilo \"Phase 2\": corte, 2s de silencio y reloj a cero\n\t\tif ( RitmoConVars.FaseHorror != FaseHorror )\n\t\t{\n\t\t\tFaseHorror = RitmoConVars.FaseHorror;\n\t\t\tNumeroPulso = -1;\n\t\t\t_silencioHasta = Time.Now + SilencioCambioFase;\n\t\t\t_proximoPulso = Time.Now + SilencioCambioFase;\n\t\t}\n\n\t\tif ( Time.Now < _silencioHasta ) return;\n\n\t\tif ( Time.Now >= _proximoPulso )\n\t\t{\n\t\t\tNumeroPulso++;\n\t\t\t_proximoPulso += DuracionPulso;\n\n\t\t\t// Tras una pausa larga (editor, hitch) no disparar r\u00e1fagas de pulsos\n\t\t\tif ( Time.Now >= _proximoPulso )\n\t\t\t\t_proximoPulso = Time.Now + DuracionPulso;\n\n\t\t\tAlPulso?.Invoke( NumeroPulso );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "HuesosAlSuelo.cs",
            "FileName": "HuesosAlSuelo.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Collections.Generic;\n\n/// <summary>\n/// [SBHBase] Planta huesos al suelo \u2014 el \"foot planting\" de los gigantes que\n/// caminan con las manos o arrastran la cabeza (pedido para el Sea Eater).\n/// Cada frame toma la pose ANIMADA del hueso (patr\u00f3n BaileProcedural:\n/// TryGetBoneTransformAnimation, sin retroalimentaci\u00f3n), busca el piso con\n/// un rayo vertical y le clava la Z al terreno (offset + suavizado).\n/// NO es IK de cadena (el codo no se dobla solo): es el truco de pegar el\n/// hueso terminal al suelo \u2014 para patas y cabezas que arrastran, rinde.\n/// OJO: comparte ClearBoneOverrides con los otros drivers de huesos\n/// (HuesosManipulados, BaileProcedural) \u2014 un solo driver por bot a la vez.\n/// </summary>\npublic sealed class HuesosAlSuelo : Component\n{\n\t[Property, Description( \"Huesos a plantar, separados por coma (ej: forearm.L_phy, forearm.R_phy)\" )]\n\tpublic string Huesos { get; set; } = \"\";\n\n\t[Property, Description( \"Offset sobre el piso en unidades (0 = apoyado justo; subilo si el hueso es el centro de una mano gorda)\" )]\n\tpublic float Altura { get; set; } = 0f;\n\n\t[Property, Description( \"Cu\u00e1nto sube el rayo por encima del hueso antes de buscar piso\" )]\n\tpublic float AlturaBusqueda { get; set; } = 256f;\n\n\t[Property, Description( \"Cu\u00e1nto puede bajar el rayo buscando piso; si no hay piso en ese tramo el hueso queda animado\" )]\n\tpublic float Alcance { get; set; } = 2048f;\n\n\t[Property, Description( \"Velocidad de acomodo (lerp exponencial); 0 = clavado duro\" )]\n\tpublic float Suavizado { get; set; } = 10f;\n\n\tSkinnedModelRenderer _renderer;\n\treadonly Dictionary<string, float> _zSuave = new();\n\tbool _aplicado;\n\n\tprotected override void OnEnabled()\n\t{\n\t\t_renderer = Components.Get<SkinnedModelRenderer>();\n\t\t_zSuave.Clear();\n\t}\n\n\tprotected override void OnDisabled() => Limpiar();\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( !_renderer.IsValid() || _renderer.SceneModel == null ) return;\n\n\t\t// Muerto o ragdoll activo: la f\u00edsica manda, nada de plantar huesos\n\t\t// (doble cerrojo: el tag puede llegar un frame tarde o vivir en el\n\t\t// root \u2014 y ModelPhysics encendido es ragdoll seguro)\n\t\tif ( GameObject.Tags.Has( \"sbh_muerto\" ) || GameObject.Root.Tags.Has( \"sbh_muerto\" )\n\t\t\t|| Components.Get<ModelPhysics>() is not null )\n\t\t{\n\t\t\tLimpiar();\n\t\t\treturn;\n\t\t}\n\n\t\tvar sm = _renderer.SceneModel;\n\t\tsm.ClearBoneOverrides();\n\t\t_aplicado = false;\n\n\t\tforeach ( var crudo in (Huesos ?? \"\").Split( ',' ) )\n\t\t{\n\t\t\tvar nombre = crudo.Trim();\n\t\t\tif ( nombre == \"\" ) continue;\n\n\t\t\tvar hueso = _renderer.Model?.Bones?.GetBone( nombre );\n\t\t\tif ( hueso == null ) continue;\n\t\t\tif ( !_renderer.TryGetBoneTransformAnimation( hueso, out var mundo ) ) continue;\n\n\t\t\t// Piso bajo el hueso, ignorando al propio bot y a los dem\u00e1s bichos\n\t\t\tvar piso = Scene.Trace\n\t\t\t\t.Ray( mundo.Position + Vector3.Up * AlturaBusqueda,\n\t\t\t\t\t  mundo.Position + Vector3.Down * Alcance )\n\t\t\t\t.IgnoreGameObjectHierarchy( GameObject.Root )\n\t\t\t\t.WithoutTags( \"player\", \"sbh_nextbot\" )\n\t\t\t\t.Run();\n\n\t\t\tif ( !piso.Hit ) continue;\n\n\t\t\tvar zObjetivo = piso.EndPosition.z + Altura;\n\t\t\tif ( Suavizado > 0f && _zSuave.TryGetValue( nombre, out var zPrevio ) )\n\t\t\t\tzObjetivo = MathX.Lerp( zPrevio, zObjetivo, Time.Delta * Suavizado );\n\t\t\t_zSuave[nombre] = zObjetivo;\n\n\t\t\tmundo.Position = mundo.Position.WithZ( zObjetivo );\n\t\t\tsm.SetBoneOverride( hueso.Index, sm.Transform.ToLocal( mundo ) );\n\t\t\t_aplicado = true;\n\t\t}\n\t}\n\n\tvoid Limpiar()\n\t{\n\t\tif ( !_aplicado ) return;\n\t\t_aplicado = false;\n\t\t_zSuave.Clear();\n\n\t\tif ( _renderer.IsValid() && _renderer.SceneModel != null )\n\t\t\t_renderer.SceneModel.ClearBoneOverrides();\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "HuesosManipulados.cs",
            "FileName": "HuesosManipulados.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Collections.Generic;\n\n/// <summary>\n/// [SBHBase] Huesos manipulados a mano (los ManipulateBone* de GMod, para el\n/// Advanced Bone Tool): guarda un ajuste por hueso (\u00e1ngulos/posici\u00f3n/escala,\n/// offsets en el MARCO LOCAL del hueso) y lo aplica cada frame POR ENCIMA de\n/// la animaci\u00f3n ya evaluada \u2014 patr\u00f3n BaileProcedural: TryGetBoneTransformAnimation\n/// lee la pose SIN overrides (sin retroalimentaci\u00f3n) y SetBoneOverride pisa.\n/// Con la lista vac\u00eda no toca nada (la animaci\u00f3n queda pura). En un bot\n/// muerto (tag sbh_muerto) se apaga: el ragdoll manda.\n/// OJO: comparte SceneModel.ClearBoneOverrides con otros drivers de huesos\n/// (ej. BaileProcedural) \u2014 dos drivers vivos en el mismo bot se pisan entre s\u00ed.\n/// </summary>\npublic sealed class HuesosManipulados : Component\n{\n\tpublic struct Ajuste\n\t{\n\t\tpublic Angles Angulos;\n\t\tpublic Vector3 Posicion;\n\t\tpublic float Escala;\n\n\t\tpublic static readonly Ajuste Identidad = new() { Escala = 1f };\n\n\t\tpublic bool EsIdentidad =>\n\t\t\tAngulos == Angles.Zero && Posicion == Vector3.Zero && Escala == 1f;\n\t}\n\n\treadonly Dictionary<string, Ajuste> _ajustes = new();\n\tSkinnedModelRenderer _renderer;\n\tbool _aplicado;\n\n\tpublic IReadOnlyDictionary<string, Ajuste> Ajustes => _ajustes;\n\n\tpublic Ajuste De( string hueso )\n\t\t=> hueso != null && _ajustes.TryGetValue( hueso, out var a ) ? a : Ajuste.Identidad;\n\n\tpublic void Fijar( string hueso, Ajuste ajuste )\n\t{\n\t\tif ( string.IsNullOrEmpty( hueso ) ) return;\n\n\t\tif ( ajuste.EsIdentidad )\n\t\t\t_ajustes.Remove( hueso );\n\t\telse\n\t\t\t_ajustes[hueso] = ajuste;\n\t}\n\n\tpublic void Reiniciar() => _ajustes.Clear();\n\n\tprotected override void OnEnabled()\n\t{\n\t\t_renderer = Components.Get<SkinnedModelRenderer>();\n\t}\n\n\tprotected override void OnDisabled() => Limpiar();\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( !_renderer.IsValid() || _renderer.SceneModel == null ) return;\n\n\t\tif ( _ajustes.Count == 0 || GameObject.Tags.Has( \"sbh_muerto\" ) )\n\t\t{\n\t\t\tLimpiar();\n\t\t\treturn;\n\t\t}\n\n\t\tvar sm = _renderer.SceneModel;\n\t\tsm.ClearBoneOverrides();\n\n\t\tforeach ( var (nombre, ajuste) in _ajustes )\n\t\t{\n\t\t\tvar hueso = _renderer.Model?.Bones?.GetBone( nombre );\n\t\t\tif ( hueso == null ) continue;\n\n\t\t\t// Pose de la ANIMACI\u00d3N (mundo), sin nuestros overrides\n\t\t\tif ( !_renderer.TryGetBoneTransformAnimation( hueso, out var mundo ) ) continue;\n\n\t\t\tvar local = sm.Transform.ToLocal( mundo );\n\n\t\t\t// Offsets en el marco local del hueso (como ManipulateBone* de GMod)\n\t\t\tlocal.Position += local.Rotation * ajuste.Posicion;\n\t\t\tlocal.Rotation *= Rotation.From( ajuste.Angulos );\n\t\t\tlocal.Scale *= ajuste.Escala;\n\n\t\t\tsm.SetBoneOverride( hueso.Index, local );\n\t\t}\n\n\t\t_aplicado = true;\n\t}\n\n\tvoid Limpiar()\n\t{\n\t\tif ( !_aplicado ) return;\n\t\t_aplicado = false;\n\n\t\tif ( _renderer.IsValid() && _renderer.SceneModel != null )\n\t\t\t_renderer.SceneModel.ClearBoneOverrides();\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "LibroFusiones.cs",
            "FileName": "LibroFusiones.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\n/// <summary>\n/// [SBHBase] El libro de recetas de fusi\u00f3n. Junta DOS fuentes:\n///  - Las recetas de PACK: assets .fusion (RecetaFusion), solo lectura.\n///  - Las recetas PROPIAS: creadas en el juego con la tool Merge en modo\n///    crear; persisten como JSON en FileSystem.Data y sobreviven sesiones.\n/// La tool y el recetario consumen Todas() sin distinguir origen (salvo\n/// para permitir borrar: solo las propias).\n/// </summary>\npublic static class LibroFusiones\n{\n\tconst string Archivo = \"sbh_fusiones.json\";\n\n\t/// <summary> Una receta lista para usar, venga del pack o del juego. </summary>\n\tpublic class Receta\n\t{\n\t\tpublic List<IngredienteFusion> Ingredientes { get; set; } = new();\n\n\t\t/// <summary> Ruta del prefab que aparece al fusionar. </summary>\n\t\tpublic string RutaResultado { get; set; } = \"\";\n\n\t\t/// <summary> Ruta de un SoundEvent opcional. </summary>\n\t\tpublic string RutaSonido { get; set; }\n\n\t\t/// <summary> Vino de un asset .fusion: no se puede borrar desde el juego. </summary>\n\t\tpublic bool DePack { get; set; }\n\t}\n\n\tstatic List<Receta> _propias;\n\n\t/// <summary> Sube con cada alta/baja de receta propia \u2014 para BuildHash de la UI. </summary>\n\tpublic static int Version { get; private set; }\n\n\t/// <summary> Todas las recetas v\u00e1lidas: primero las del pack, despu\u00e9s las propias. </summary>\n\tpublic static IEnumerable<Receta> Todas()\n\t{\n\t\tforeach ( var r in ResourceLibrary.GetAll<RecetaFusion>() )\n\t\t{\n\t\t\tif ( r.Resultado == null ) continue;\n\t\t\tif ( r.Ingredientes == null || r.Ingredientes.Count == 0 ) continue;\n\n\t\t\tyield return new Receta\n\t\t\t{\n\t\t\t\tIngredientes = r.Ingredientes,\n\t\t\t\tRutaResultado = r.Resultado.ResourcePath,\n\t\t\t\tRutaSonido = r.Sonido?.ResourcePath,\n\t\t\t\tDePack = true,\n\t\t\t};\n\t\t}\n\n\t\tforeach ( var r in Propias() )\n\t\t\tyield return r;\n\t}\n\n\tstatic List<Receta> Propias()\n\t{\n\t\tif ( _propias != null ) return _propias;\n\n\t\t_propias = new List<Receta>();\n\n\t\ttry\n\t\t{\n\t\t\tif ( FileSystem.Data.FileExists( Archivo ) )\n\t\t\t\t_propias = Json.Deserialize<List<Receta>>( FileSystem.Data.ReadAllText( Archivo ) ) ?? new();\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"LibroFusiones: no pude leer {Archivo} \u2014 {e.Message}\" );\n\t\t}\n\n\t\tforeach ( var r in _propias )\n\t\t\tr.DePack = false;\n\n\t\treturn _propias;\n\t}\n\n\t/// <summary>\n\t/// Guarda una receta creada en el juego. Si ya hab\u00eda una propia con los\n\t/// MISMOS ingredientes, se reemplaza \u2014 re-grabar una receta es la forma\n\t/// de cambiarle el resultado.\n\t/// </summary>\n\tpublic static void Guardar( Receta receta )\n\t{\n\t\tvar propias = Propias();\n\t\tpropias.RemoveAll( r => MismosIngredientes( r, receta ) );\n\t\tpropias.Add( receta );\n\t\tPersistir();\n\t}\n\n\t/// <summary> Borra una receta propia (las de pack no se tocan). </summary>\n\tpublic static void Borrar( Receta receta )\n\t{\n\t\tif ( receta.DePack ) return;\n\n\t\tPropias().RemoveAll( r => MismosIngredientes( r, receta )\n\t\t\t&& r.RutaResultado == receta.RutaResultado );\n\t\tPersistir();\n\t}\n\n\t/// <summary> El bot\u00f3n nuclear: borra TODAS las recetas propias de un saque. </summary>\n\tpublic static void BorrarTodas()\n\t{\n\t\tPropias().Clear();\n\t\tPersistir();\n\t}\n\n\tstatic void Persistir()\n\t{\n\t\ttry\n\t\t{\n\t\t\tFileSystem.Data.WriteAllText( Archivo, Json.Serialize( _propias ) );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"LibroFusiones: no pude guardar {Archivo} \u2014 {e.Message}\" );\n\t\t}\n\n\t\tVersion++;\n\t}\n\n\t// Mismo multiset de ingredientes (nombres sin may\u00fasculas, cantidades sumadas)\n\tstatic bool MismosIngredientes( Receta a, Receta b )\n\t{\n\t\tstatic Dictionary<string, int> Mapa( Receta r ) => r.Ingredientes\n\t\t\t.GroupBy( i => i.Nombre?.Trim().ToLowerInvariant() ?? \"\" )\n\t\t\t.ToDictionary( g => g.Key, g => g.Sum( i => Math.Max( 1, i.Cantidad ) ) );\n\n\t\tvar ma = Mapa( a );\n\t\tvar mb = Mapa( b );\n\n\t\treturn ma.Count == mb.Count\n\t\t\t&& ma.All( kv => mb.TryGetValue( kv.Key, out var c ) && c == kv.Value );\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "RitmoPersonaje.cs",
            "FileName": "RitmoPersonaje.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\n\n/// <summary>\n/// [SBHBase] El ritmo de un sprunki: en CADA pulso del DirectorRitmo\n/// dispara su loop (variante A en \"Loop 1\", B en \"Loop 2\", como el juego\n/// original) si est\u00e1 \"en pantalla\" (cerca de la c\u00e1mara) y vivo. Cada\n/// variante suena entonces cada 9.6s, intercaladas \u2014 el solape de sus colas\n/// es lo que hace el loop continuo (clave en los sets horror de ~7s).\n/// Re-disparar la MISMA variante corta su disparo anterior (sem\u00e1ntica de\n/// Scratch: \"start sound\" reinicia el sonido), as\u00ed los audios que pasan de\n/// 9.6s (creepy, better_windup...) se recortan solos como en el original.\n/// No depende de SBHBase: detecta la muerte por la presencia del ragdoll\n/// (ModelPhysics o el Rigidbody del plan B de MuerteNextbot).\n/// </summary>\npublic sealed class RitmoPersonaje : Component\n{\n\t[Property, Description( \"Loop normal, variante A (pulsos 'Loop 1'). Vac\u00edo = personaje mudo en fase normal (como Black en el original)\" )]\n\tpublic SoundEvent LoopNormalA { get; set; }\n\n\t[Property, Description( \"Loop normal, variante B (pulsos 'Loop 2'). Vac\u00edo = repite la A\" )]\n\tpublic SoundEvent LoopNormalB { get; set; }\n\n\t[Property, Description( \"Loop de la fase horror, variante A. Vac\u00edo = sigue con el normal\" )]\n\tpublic SoundEvent LoopHorrorA { get; set; }\n\n\t[Property, Description( \"Loop de la fase horror, variante B. Vac\u00edo = repite la A\" )]\n\tpublic SoundEvent LoopHorrorB { get; set; }\n\n\t[Property, Description( \"Distancia m\u00e1xima a la c\u00e1mara para que dispare su ritmo \u2014 el 'estar en pantalla'\" )]\n\tpublic float DistanciaAudible { get; set; } = 3000f;\n\n\t[Property, Description( \"Segundos del fade de silencio al morir (0 = corte seco)\" )]\n\tpublic float FadeMuerte { get; set; } = 0.35f;\n\n\tDirectorRitmo _director;\n\n\t// Un handle por variante (0 = A/pulsos pares, 1 = B/impares): igual que los\n\t// pares de sonidos del original (bass3/bass4, amen3/amen4...), cada slot se\n\t// reinicia a s\u00ed mismo cada 9.6s sin cortar al otro\n\treadonly SoundHandle[] _handles = new SoundHandle[2];\n\treadonly float[] _volumenes = { 1f, 1f }; // volumen al disparar (respeta el del .sound)\n\tfloat _fade;              // avance del fade de muerte\n\tbool _muriendo;\n\n\tprotected override void OnEnabled()\n\t{\n\t\t_director = Scene.GetSystem<DirectorRitmo>();\n\t\tif ( _director != null )\n\t\t\t_director.AlPulso += EnPulso;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tif ( _director != null )\n\t\t\t_director.AlPulso -= EnPulso;\n\n\t\tfor ( var i = 0; i < _handles.Length; i++ )\n\t\t{\n\t\t\t_handles[i]?.Stop();\n\t\t\t_handles[i] = null;\n\t\t}\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\t// Los loops viajan con el personaje (si no, quedan sonando donde arrancaron)\n\t\tfor ( var i = 0; i < _handles.Length; i++ )\n\t\t{\n\t\t\tif ( _handles[i] != null && _handles[i].IsValid() )\n\t\t\t\t_handles[i].Position = WorldPosition;\n\t\t}\n\n\t\t// Muerte: el ritmo se apaga YA, con un fade cortito para que no haga\n\t\t// click \u2014 la cola musical no debe sobrevivir al ragdoll\n\t\tvar muerto = EstaMuerto();\n\n\t\tif ( muerto && !_muriendo )\n\t\t{\n\t\t\t_muriendo = true;\n\t\t\t_fade = 0f;\n\t\t}\n\t\telse if ( !muerto )\n\t\t{\n\t\t\t_muriendo = false; // por si algo lo revive: vuelve a sonar\n\t\t}\n\n\t\tif ( _muriendo )\n\t\t{\n\t\t\t_fade += Time.Delta;\n\t\t\tvar restante = FadeMuerte > 0f ? 1f - (_fade / FadeMuerte) : 0f;\n\n\t\t\tfor ( var i = 0; i < _handles.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( _handles[i] == null || !_handles[i].IsValid() ) continue;\n\n\t\t\t\tif ( restante <= 0f )\n\t\t\t\t{\n\t\t\t\t\t_handles[i].Stop();\n\t\t\t\t\t_handles[i] = null;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t_handles[i].Volume = _volumenes[i] * restante;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvoid EnPulso( int numero )\n\t{\n\t\tif ( !PuedeSonar() ) return;\n\n\t\tvar horror = _director.FaseHorror && (LoopHorrorA != null || LoopHorrorB != null);\n\n\t\t// Cada pulso dispara: A en los pares (\"Loop 1\"), B en los impares\n\t\t// (\"Loop 2\"). Con B vac\u00eda, el mismo sonido ocupa los dos slots \u2014 igual\n\t\t// que los pares duplicados del original (amen3/amen4, kick/kick2)\n\t\tvar esA = numero % 2 == 0;\n\n\t\tvar sonido = Elegir( horror, esA );\n\t\tif ( sonido == null ) return;\n\n\t\tvar slot = esA ? 0 : 1;\n\n\t\t// Re-disparar la variante REINICIA su disparo anterior (sem\u00e1ntica\n\t\t// Scratch); la cola de la OTRA variante sigue sonando \u2014 ese solape\n\t\t// intercalado es el loop continuo del original\n\t\t_handles[slot]?.Stop();\n\t\t_handles[slot] = Sound.Play( sonido, WorldPosition );\n\n\t\tif ( _handles[slot] != null && _handles[slot].IsValid() )\n\t\t\t_volumenes[slot] = _handles[slot].Volume;\n\n\t\t// Aviso gen\u00e9rico \"estoy cantando este pulso\" (lo consume el puente\n\t\t// del juego para la cara de canto de las expresiones)\n\t\tAlCantar?.Invoke( DirectorRitmo.DuracionPulso );\n\t}\n\n\t/// <summary>\n\t/// Disparado en cada retrigger del loop, con los segundos que cubre hasta\n\t/// el pr\u00f3ximo (un pulso, 4.8). Quien quiera animar el canto se cuelga ac\u00e1.\n\t/// </summary>\n\tpublic event System.Action<float> AlCantar;\n\n\tSoundEvent Elegir( bool horror, bool esA )\n\t{\n\t\tif ( horror )\n\t\t\treturn esA ? (LoopHorrorA ?? LoopHorrorB) : (LoopHorrorB ?? LoopHorrorA);\n\n\t\treturn esA ? (LoopNormalA ?? LoopNormalB) : (LoopNormalB ?? LoopNormalA);\n\t}\n\n\t/// <summary>\n\t/// Cad\u00e1ver = tag \"sbh_muerto\" (lo pone MuerteNextbot) o ragdoll activo.\n\t/// OJO: no mirar Rigidbody \u2014 un bot alzado por la physgun (AgarreNextbot)\n\t/// lleva Rigidbody temporal y est\u00e1 bien vivo: que siga sonando en el aire.\n\t/// </summary>\n\tbool EstaMuerto()\n\t{\n\t\tif ( GameObject.Tags.Has( \"sbh_muerto\" ) ) return true;\n\t\tif ( Components.Get<ModelPhysics>().IsValid() ) return true;\n\t\treturn false;\n\t}\n\n\tbool PuedeSonar()\n\t{\n\t\tif ( EstaMuerto() ) return false;\n\n\t\t// \"En pantalla\": cerca de la c\u00e1mara (el falloff del .sound hace el resto)\n\t\tvar cam = Scene.Camera;\n\t\tif ( cam.IsValid() && cam.WorldPosition.Distance( WorldPosition ) > DistanciaAudible )\n\t\t\treturn false;\n\n\t\treturn true;\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/EventosAnim.cs",
            "FileName": "EventosAnim.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Collections.Generic;\nusing System.Linq;\n\n/// <summary>\n/// [SBHBase] EVENTOS DE ANIMACI\u00d3N \u2014 los animevents de GMod (Impact,\n/// ImpactKill, sonidos...) como datos editables en vivo: por personaje, un\n/// mapa anim \u2192 eventos en FRAMES a 30fps (el idioma de Blender y del QC).\n///\n/// Es el hermano del sistema de partituras de caras (PartituraStore), pero\n/// la l\u00ednea de tiempo es cada ANIMACI\u00d3N en vez del pulso musical. Capas de\n/// origen (mismo dise\u00f1o): JSON local de FileSystem.Data (lo pinta el editor\n/// en vivo, o Notepad mientras tanto) \u2192 de f\u00e1brica horneada en la librer\u00eda\n/// (EventosDeFabrica, tools/hornear_eventos.bat) \u2192 fallback del prefab\n/// (RetardoImpacto cl\u00e1sico: quien no tiene eventos no cambia en nada).\n///\n/// Consumidores hoy: NextbotBrain (Impact de CADA variante del pool de\n/// ataques \u2014 antes un solo RetardoImpacto desincronizaba a 3 de 4 golpes) y\n/// SbhJumpscareCine (ImpactKill de execution). Ma\u00f1ana: pasos, efectos, y el\n/// editor visual estilo Beat.\n/// </summary>\npublic static class EventosAnim\n{\n\tpublic class Evento\n\t{\n\t\tpublic string Anim { get; set; }\n\t\tpublic int Frame { get; set; }\n\t\tpublic string Nombre { get; set; }\n\n\t\t/// <summary> Par\u00e1metro opcional del evento: la ruta del .sound en Sound/Step; a futuro \"prefab@hueso\" en Efecto. </summary>\n\t\tpublic string Dato { get; set; }\n\n\t\t/// <summary>\n\t\t/// Capa visual del editor (0 = la primera). Eventos simult\u00e1neos viven\n\t\t/// en capas distintas para verse todos; el DESPACHADOR la ignora \u2014 se\n\t\t/// disparan todas las capas por igual. JSON viejos: sin el campo, 0.\n\t\t/// </summary>\n\t\tpublic int Capa { get; set; }\n\t}\n\n\tpublic class Datos\n\t{\n\t\tpublic List<Evento> Eventos { get; set; } = new();\n\n\t\t/// <summary>\n\t\t/// Head-tracking APAGADO para este personaje (2026-08-10): en rigs de\n\t\t/// GMod donde \"head\" es una parte especial \u2014 la tapa del libro de\n\t\t/// Buuik, la tapa del pastel de Cake \u2014 la mirada la retuerce feo.\n\t\t/// Lo togglea el editor de Anim Events y viaja con el horneado.\n\t\t/// </summary>\n\t\tpublic bool SinCabeza { get; set; }\n\n\t\t/// <summary>\n\t\t/// Fuerza del salto EN POSESI\u00d3N de este personaje (2026-08-10).\n\t\t/// 0 = la que trae su prefab (SaltoNextbot.FuerzaSaltoPosesion, que\n\t\t/// escribe el addon de Blender). Se afina desde el editor Anim Events\n\t\t/// y viaja horneada, como todo lo dem\u00e1s.\n\t\t/// </summary>\n\t\tpublic float FuerzaSalto { get; set; }\n\n\t\t/// <summary>\n\t\t/// Velocidad de CAZA afinada (2026-08-10, \"el Siren patina\"): unidades\n\t\t/// por segundo al perseguir y al correr pose\u00eddo. 0 = la del prefab\n\t\t/// (NavMeshAgent.MaxSpeed, que escribe el addon). Editor \u2192 JSON \u2192\n\t\t/// horneado, la tripleta de siempre.\n\t\t/// </summary>\n\t\tpublic float Velocidad { get; set; }\n\n\t\t/// <summary>\n\t\t/// VelocidadAnimacion afinada (2026-08-10): a cu\u00e1ntas unidades/seg de\n\t\t/// suelo corresponde la anim de marcha a ritmo 1 \u2014 LA perilla del\n\t\t/// patinaje. 0 = la del prefab.\n\t\t/// </summary>\n\t\tpublic float VelocidadAnim { get; set; }\n\n\t\t/// <summary>\n\t\t/// Multiplicador de velocidad POR ANIMACI\u00d3N (2026-08-10, la feature\n\t\t/// agendada): anim (min\u00fasculas) \u2192 mult. 1.5 = \"el run de este bicho\n\t\t/// va 50% m\u00e1s r\u00e1pido, para siempre\". Ausente = 1 (natural). Gana\n\t\t/// sobre el tope RitmoAnimMax y sobre la regla \"ataque nunca\n\t\t/// acelerado\": es la palabra expl\u00edcita del artista.\n\t\t/// </summary>\n\t\tpublic Dictionary<string, float> RitmosAnim { get; set; } = new();\n\n\t\t/// <summary>\n\t\t/// Vida m\u00e1xima afinada (2026-08-12, pedido de la primera rese\u00f1a del\n\t\t/// juego): 0 = la del prefab (Vida.VidaMaxima). Se aplica al SPAWN \u2014\n\t\t/// los bots ya paridos conservan la suya. Editor \u2192 JSON \u2192 horneado.\n\t\t/// </summary>\n\t\tpublic float Vida { get; set; }\n\n\t\t/// <summary>\n\t\t/// Da\u00f1o por golpe afinado (2026-08-12): 0 = el del prefab\n\t\t/// (NextbotBrain.Danio). Se aplica al spawn, como la vida.\n\t\t/// </summary>\n\t\tpublic float Danio { get; set; }\n\n\t\t/// <summary>\n\t\t/// Super da\u00f1o afinado (2026-08-12, el \"Super Damage\" de DRGBase):\n\t\t/// da\u00f1o alternativo que sale con ProbDanioSuper. 0 = el del prefab.\n\t\t/// </summary>\n\t\tpublic float DanioSuper { get; set; }\n\n\t\t/// <summary> Probabilidad del super da\u00f1o en % por golpe (0 = la del prefab). </summary>\n\t\tpublic float ProbDanioSuper { get; set; }\n\n\t\t/// <summary> Regeneraci\u00f3n pasiva afinada, HP/seg (0 = la del prefab). </summary>\n\t\tpublic float Regeneracion { get; set; }\n\n\t\t/// <summary> Cooldown entre ataques afinado, en segundos (0 = el del prefab). </summary>\n\t\tpublic float CooldownAtaque { get; set; }\n\t}\n\n\t/// <summary> \u00bfEste personaje tiene el head-tracking apagado por datos? </summary>\n\tpublic static bool CabezaApagada( string personaje )\n\t\t=> !string.IsNullOrEmpty( personaje ) && Cargar( personaje ).SinCabeza;\n\n\t/// <summary> Fuerza de salto afinada para este personaje, o 0 si manda el prefab. </summary>\n\tpublic static float FuerzaSaltoDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).FuerzaSalto;\n\n\t/// <summary> Velocidad de caza afinada para este personaje, o 0 si manda el prefab. </summary>\n\tpublic static float VelocidadDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Velocidad;\n\n\t/// <summary> VelocidadAnimacion afinada para este personaje, o 0 si manda el prefab. </summary>\n\tpublic static float VelocidadAnimDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).VelocidadAnim;\n\n\t/// <summary> Vida m\u00e1xima afinada para este personaje, o 0 si manda el prefab. </summary>\n\tpublic static float VidaDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Vida;\n\n\t/// <summary> Da\u00f1o por golpe afinado para este personaje, o 0 si manda el prefab. </summary>\n\tpublic static float DanioDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Danio;\n\n\t/// <summary> Super da\u00f1o afinado para este personaje, o 0 si manda el prefab. </summary>\n\tpublic static float DanioSuperDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).DanioSuper;\n\n\t/// <summary> Probabilidad (%) del super da\u00f1o afinada, o 0 si manda el prefab. </summary>\n\tpublic static float ProbDanioSuperDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).ProbDanioSuper;\n\n\t/// <summary> Regeneraci\u00f3n pasiva afinada (HP/seg), o 0 si manda el prefab. </summary>\n\tpublic static float RegeneracionDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Regeneracion;\n\n\t/// <summary> Cooldown de ataque afinado (seg), o 0 si manda el prefab. </summary>\n\tpublic static float CooldownDe( string personaje )\n\t\t=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).CooldownAtaque;\n\n\t/// <summary> Multiplicador de velocidad de UNA anim de este personaje (1 = natural). </summary>\n\tpublic static float RitmoAnimacionDe( string personaje, string anim )\n\t{\n\t\tif ( string.IsNullOrEmpty( personaje ) || string.IsNullOrEmpty( anim ) ) return 1f;\n\n\t\tvar ritmos = Cargar( personaje ).RitmosAnim;\n\t\tif ( ritmos == null || ritmos.Count == 0 ) return 1f;\n\n\t\treturn (ritmos.TryGetValue( anim, out var m ) || ritmos.TryGetValue( anim.ToLowerInvariant(), out m ))\n\t\t\t&& m > 0f ? m : 1f;\n\t}\n\n\tconst string Carpeta = \"sbh_animevents\";\n\tconst float Fps = 30f;\n\n\tstatic readonly Dictionary<string, Datos> _cache = new();\n\n\t/// <summary> El personaje seg\u00fan la convenci\u00f3n de todo el proyecto: el nombre del vmdl. </summary>\n\tpublic static string PersonajeDe( SkinnedModelRenderer renderer )\n\t\t=> System.IO.Path.GetFileNameWithoutExtension( renderer?.Model?.ResourcePath ?? \"\" );\n\n\tstatic string Ruta( string personaje ) => $\"{Carpeta}/{personaje}.json\";\n\n\tpublic static Datos Cargar( string personaje )\n\t{\n\t\tif ( string.IsNullOrEmpty( personaje ) ) return new Datos();\n\t\tif ( _cache.TryGetValue( personaje, out var datos ) ) return datos;\n\n\t\tdatos = FileSystem.Data.FileExists( Ruta( personaje ) )\n\t\t\t? FileSystem.Data.ReadJson<Datos>( Ruta( personaje ) )\n\t\t\t: null;\n\n\t\t// Sin JSON local mandan los eventos DE F\u00c1BRICA horneados en la\n\t\t// librer\u00eda (tools/hornear_eventos.bat): lo pintado ac\u00e1 viaja al\n\t\t// publicar y al equipo, igual que las partituras.\n\t\tdatos ??= EventosDeFabrica.Obtener( personaje ) ?? new Datos();\n\n\t\t_cache[personaje] = datos;\n\t\treturn datos;\n\t}\n\n\tpublic static void Guardar( string personaje, Datos datos )\n\t{\n\t\tFileSystem.Data.CreateDirectory( Carpeta );\n\t\tFileSystem.Data.WriteJson( Ruta( personaje ), datos );\n\t\t_cache[personaje] = datos;\n\t}\n\n\t/// <summary> El editor (o un reload a mano) invalida y los bots releen al pr\u00f3ximo golpe. </summary>\n\tpublic static void Invalidar( string personaje = null )\n\t{\n\t\tif ( personaje == null ) _cache.Clear();\n\t\telse _cache.Remove( personaje );\n\t}\n\n\t/// <summary> \u00bfEsta anim tiene pintado al menos un evento de este nombre? </summary>\n\tpublic static bool TieneEvento( string personaje, string anim, string evento )\n\t{\n\t\tif ( string.IsNullOrEmpty( anim ) || string.IsNullOrEmpty( evento ) ) return false;\n\n\t\treturn Cargar( personaje ).Eventos.Any( e =>\n\t\t\tstring.Equals( e.Anim, anim, System.StringComparison.OrdinalIgnoreCase ) &&\n\t\t\tstring.Equals( e.Nombre, evento, System.StringComparison.OrdinalIgnoreCase ) );\n\t}\n\n\t/// <summary>\n\t/// Segundos (a ritmo 1) del evento pedido en esa anim, o null si no est\u00e1\n\t/// pintado \u2014 el consumidor cae a su fallback cl\u00e1sico.\n\t/// </summary>\n\tpublic static float? TiempoDe( string personaje, string anim, string evento )\n\t{\n\t\tif ( string.IsNullOrEmpty( anim ) || string.IsNullOrEmpty( evento ) ) return null;\n\n\t\tvar ev = Cargar( personaje ).Eventos.FirstOrDefault( e =>\n\t\t\tstring.Equals( e.Anim, anim, System.StringComparison.OrdinalIgnoreCase ) &&\n\t\t\tstring.Equals( e.Nombre, evento, System.StringComparison.OrdinalIgnoreCase ) );\n\n\t\treturn ev != null ? ev.Frame / Fps : null;\n\t}\n\n\t[ConCmd( \"sbh_eventos_recargar\", Help = \"Relee los JSON de eventos de animaci\u00f3n (sbh_animevents/) \u2014 para editar con Notepad sin reiniciar.\" )]\n\tpublic static void Recargar()\n\t{\n\t\tInvalidar();\n\t\tLog.Info( \"\ud83c\udf9e EventosAnim: cach\u00e9 limpiada, los JSON se releen al pr\u00f3ximo uso\" );\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/PartiturasDeFabrica.cs",
            "FileName": "PartiturasDeFabrica.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\r\n\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// [SBHBase] Partituras de fabrica - GENERADO por tools/hornear_partituras.bat\r\n/// (2026-08-05 21:22) a partir de lo pintado en el editor Beat. NO editar a mano: pintar\r\n/// en el juego y volver a hornear. Viajan con la libreria (publicacion y equipo);\r\n/// el JSON local del jugador, si existe, las pisa (PartituraStore.Cargar).\r\n/// Pistas por personaje: LoopA, LoopB, HorrorA, HorrorB (48 casilleros, -1 = vacio).\r\n/// </summary>\r\npublic static class PartiturasDeFabrica\r\n{\r\n\tstatic readonly Dictionary<string, int[][]> Tabla = new()\r\n\t{\r\n\t\t[\"sprunki_hrr_oren\"] = new[]\r\n\t\t{\r\n\t\t\tnew[] { 0, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, 0, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t},\r\n\t\t[\"sprunki_jevin\"] = new[]\r\n\t\t{\r\n\t\t\tnew[] { 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 2, -1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, 3, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { 0, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 2, -1, -1, -1, -1, -1, -1, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t},\r\n\t\t[\"sprunki_mrfuncomputer\"] = new[]\r\n\t\t{\r\n\t\t\tnew[] { 2, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, 3, -1, -1, -1, -1, -1, -1, 4, -1, -1, -1, -1, 5, -1, -1, -1, -1, -1, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 7, -1 },\r\n\t\t\tnew[] { 3, -1, -1, -1, -1, 4, -1, -1, -1, -1, -1, 5, -1, -1, -1, -1, -1, 6, -1, -1, -1, -1, -1, 7, -1, -1, -1, -1, -1, 10, -1, -1, -1, -1, -1, 9, -1, -1, -1, -1, -1, 10, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t},\r\n\t\t[\"sprunki_oren\"] = new[]\r\n\t\t{\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t},\r\n\t\t[\"sprunki_owakcx\"] = new[]\r\n\t\t{\r\n\t\t\tnew[] { 2, -1, -1, 0, -1, -1, 2, -1, 0, -1, -1, 2, -1, -1, -1, -1, -1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t\tnew[] { -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 },\r\n\t\t},\r\n\t};\r\n\r\n\tpublic static PartituraStore.Datos Obtener( string personaje )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( personaje ) ) return null;\r\n\t\tif ( !Tabla.TryGetValue( personaje, out var pistas ) ) return null;\r\n\r\n\t\treturn new PartituraStore.Datos\r\n\t\t{\r\n\t\t\tLoopA = new List<int>( pistas[0] ),\r\n\t\t\tLoopB = new List<int>( pistas[1] ),\r\n\t\t\tHorrorA = new List<int>( pistas[2] ),\r\n\t\t\tHorrorB = new List<int>( pistas[3] ),\r\n\t\t};\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/RespawnAlMorir.cs",
            "FileName": "RespawnAlMorir.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Linq;\n\n/// <summary>\n/// Al morir la Vida de este GameObject, lo teletransporta a un\n/// SpawnPoint aleatorio y lo revive. Para jugadores.\n/// </summary>\npublic sealed class RespawnAlMorir : Component\n{\n\t[RequireComponent] Vida Vida { get; set; }\n\n\t[Property, Description( \"Sonido al morir (opcional)\" )]\n\tpublic SoundEvent SonidoMuerte { get; set; }\n\n\tprotected override void OnEnabled()\n\t{\n\t\tVida.AlMorir += Respawnear;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tVida.AlMorir -= Respawnear;\n\t}\n\n\tvoid Respawnear()\n\t{\n\t\tif ( SonidoMuerte != null )\n\t\t\tSound.Play( SonidoMuerte, WorldPosition );\n\n\t\tvar spawns = Scene.GetAllComponents<SpawnPoint>().ToList();\n\t\tif ( spawns.Count > 0 )\n\t\t{\n\t\t\tvar spawn = Game.Random.FromList( spawns );\n\t\t\tWorldPosition = spawn.WorldPosition;\n\t\t}\n\n\t\tVida.Revivir();\n\t\tLog.Info( \"\ud83d\udc80 Jugador muerto \u2192 respawn con vida completa\" );\n\t}\n}"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/TemblorCamara.cs",
            "FileName": "TemblorCamara.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\n\n/// <summary>\n/// [SBHBase] TEMBLOR DE C\u00c1MARA gen\u00e9rico (2026-08-10, pedido de H\u00e9ctor para\n/// los pisotones del Siren Head). Modelo de \"trauma\": los golpes suman\n/// trauma, el trauma decae solo, y mientras haya trauma la vista vibra\n/// (offset + roll aleatorios, curva cuadr\u00e1tica).\n///\n/// Es un ICameraModifier con el ORDEN M\u00c1S ALTO: corre al final de la cadena\n/// de c\u00e1mara del engine, sobre la vista YA calculada del frame \u2014 primera\n/// persona, posesi\u00f3n (\u00f3rbita), cine \u2014 as\u00ed que no hay realimentaci\u00f3n posible\n/// (la versi\u00f3n anterior mutaba la c\u00e1mara post-update y la \u00f3rbita de la\n/// posesi\u00f3n heredaba la sacudida: \"se buguea la c\u00e1mara\", 2026-08-10).\n///\n/// Cualquier sistema puede llamar <see cref=\"Sacudir\"/>: pisadas de gigante,\n/// explosiones, el tr\u00edpode cuando vuelva del taller...\n/// </summary>\npublic sealed class TemblorCamara : Component, ICameraModifier\n{\n\tstatic TemblorCamara _instancia;\n\tfloat _trauma;\n\n\t/// <summary> Suma un golpe de temblor (0.1 = apenas, 1 = terremoto). </summary>\n\tpublic static void Sacudir( Scene scene, float intensidad )\n\t{\n\t\tif ( intensidad <= 0f ) return;\n\t\tvar t = Asegurar( scene );\n\t\tif ( t.IsValid() )\n\t\t\tt._trauma = ( t._trauma + intensidad ).Clamp( 0f, 1.5f );\n\t}\n\n\t// Patr\u00f3n FadeHorror.Asegurar: un solo GameObject de servicio en la escena\n\tstatic TemblorCamara Asegurar( Scene scene )\n\t{\n\t\tif ( _instancia.IsValid() ) return _instancia;\n\t\tif ( scene == null ) return null;\n\n\t\tvar go = scene.CreateObject();\n\t\tgo.Name = \"sbh_temblor_camara\";\n\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;\n\t\t_instancia = go.Components.Create<TemblorCamara>();\n\t\treturn _instancia;\n\t}\n\n\tint ICameraModifier.CameraOrder => 9999; // el \u00faltimo de la cadena\n\n\tvoid ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )\n\t{\n\t\tif ( _trauma <= 0.001f ) { _trauma = 0f; return; }\n\n\t\tvar fuerza = _trauma * _trauma;\n\t\tview.Position += Vector3.Random * ( 6f * fuerza );\n\t\tview.Rotation *= Rotation.From(\n\t\t\tGame.Random.Float( -1.2f, 1.2f ) * fuerza,\n\t\t\tGame.Random.Float( -1.2f, 1.2f ) * fuerza,\n\t\t\tGame.Random.Float( -2.5f, 2.5f ) * fuerza );\n\n\t\t// Decae solo: un golpe de 0.4 dura ~un tercio de segundo\n\t\t_trauma = System.MathF.Max( 0f, _trauma - Time.Delta * 2.2f );\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "EfectosNextbot.cs",
            "FileName": "EfectosNextbot.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\n\n/// <summary>\n/// [SBHBase] Efectos de part\u00edculas del nextbot: al aparecer, al morir, al\n/// recibir da\u00f1o y cuando su golpe conecta. Cada efecto es un prefab one-shot\n/// (emisor con Loop apagado y DestroyOnEnd encendido) que se clona en el\n/// lugar y se limpia solo cuando las part\u00edculas terminan \u2014 mismo patr\u00f3n que\n/// los efectos del rollermine de sandbox. Cualquier campo vac\u00edo simplemente\n/// no emite nada.\n/// Requiere Vida en el mismo GameObject para muerte/da\u00f1o y NextbotBrain\n/// para el golpe conectado; sin ellos, esos efectos quedan mudos.\n/// </summary>\npublic sealed class EfectosNextbot : Component\n{\n\t[Property, Description( \"Prefab de part\u00edculas al aparecer (se clona en los pies)\" )]\n\tpublic GameObject EfectoSpawn { get; set; }\n\n\t[Property, Description( \"Prefab de part\u00edculas al morir (se clona en el torso)\" )]\n\tpublic GameObject EfectoMuerte { get; set; }\n\n\t[Property, Description( \"Prefab de part\u00edculas al recibir da\u00f1o, tipo sangre (en el torso; el golpe mortal usa solo el de muerte)\" )]\n\tpublic GameObject EfectoDanio { get; set; }\n\n\t[Property, Description( \"Prefab de part\u00edculas cuando SU golpe conecta (se clona en la v\u00edctima)\" )]\n\tpublic GameObject EfectoGolpe { get; set; }\n\n\t[Property, Description( \"Altura del torso sobre los pies, donde nacen los efectos de da\u00f1o y muerte\" )]\n\tpublic float AlturaTorso { get; set; } = 40f;\n\n\tVida _vida;\n\tNextbotBrain _cerebro;\n\n\tVector3 Torso => WorldPosition + Vector3.Up * AlturaTorso;\n\n\tprotected override void OnEnabled()\n\t{\n\t\t_vida = Components.Get<Vida>();\n\t\t_cerebro = Components.Get<NextbotBrain>( true );\n\n\t\tif ( _vida.IsValid() )\n\t\t{\n\t\t\t_vida.AlRecibirDanio += AlDaniado;\n\t\t\t_vida.AlMorir += AlMorirBot;\n\t\t}\n\n\t\tif ( _cerebro.IsValid() )\n\t\t\t_cerebro.AlGolpear += AlConectarGolpe;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tif ( _vida.IsValid() )\n\t\t{\n\t\t\t_vida.AlRecibirDanio -= AlDaniado;\n\t\t\t_vida.AlMorir -= AlMorirBot;\n\t\t}\n\n\t\tif ( _cerebro.IsValid() )\n\t\t\t_cerebro.AlGolpear -= AlConectarGolpe;\n\t}\n\n\tprotected override void OnStart()\n\t{\n\t\tClonar( EfectoSpawn, WorldPosition );\n\t}\n\n\tvoid AlDaniado( float cantidad )\n\t{\n\t\t// El golpe mortal ya tiene su propio efecto (AlMorir): no duplicar\n\t\tif ( _vida.IsValid() && _vida.EstaMuerto ) return;\n\n\t\tClonar( EfectoDanio, Torso );\n\t}\n\n\tvoid AlMorirBot()\n\t{\n\t\tClonar( EfectoMuerte, Torso );\n\t}\n\n\tvoid AlConectarGolpe( IVida victima, Vector3 punto )\n\t{\n\t\tClonar( EfectoGolpe, punto );\n\t}\n\n\tstatic void Clonar( GameObject prefab, Vector3 posicion )\n\t{\n\t\tif ( prefab == null || !prefab.IsValid() ) return;\n\n\t\tvar go = prefab.Clone( posicion );\n\t\tif ( !go.IsValid() ) return;\n\n\t\t// El DestroyOnEnd del emisor limpia las capas de part\u00edculas, pero el\n\t\t// objeto ra\u00edz del prefab quedar\u00eda hu\u00e9rfano en la escena para siempre\n\t\t// (TemporaryEffect: el mismo componente del engine que usan los gibs)\n\t\tgo.Components.Create<TemporaryEffect>().DestroyAfterSeconds = 5f;\n\t}\n}\n\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "PartituraStore.cs",
            "FileName": "PartituraStore.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Collections.Generic;\n\n/// <summary>\n/// [SBHBase] Guardado de partituras de caras, una por personaje, en\n/// FileSystem.Data (sprunki_partituras/&lt;personaje&gt;.json). El editor del\n/// men\u00fa C guarda ac\u00e1 en cada pincelada; PartituraInyector carga al inyectar.\n/// </summary>\npublic static class PartituraStore\n{\n\tpublic class Datos\n\t{\n\t\tpublic List<int> LoopA { get; set; } = PartituraCaras.PistaVacia();\n\t\tpublic List<int> LoopB { get; set; } = PartituraCaras.PistaVacia();\n\t\tpublic List<int> HorrorA { get; set; } = PartituraCaras.PistaVacia();\n\t\tpublic List<int> HorrorB { get; set; } = PartituraCaras.PistaVacia();\n\t}\n\n\tconst string Carpeta = \"sprunki_partituras\";\n\n\tstatic string Ruta( string personaje ) => $\"{Carpeta}/{personaje}.json\";\n\n\tpublic static Datos Cargar( string personaje )\n\t{\n\t\tif ( string.IsNullOrEmpty( personaje ) ) return null;\n\n\t\t// El JSON local (lo \u00faltimo pintado en el editor) manda; sin \u00e9l, las\n\t\t// partituras de f\u00e1brica horneadas en la librer\u00eda (tools/hornear_partituras)\n\t\tif ( !FileSystem.Data.FileExists( Ruta( personaje ) ) )\n\t\t\treturn DeFabrica( personaje );\n\n\t\tvar datos = FileSystem.Data.ReadJson<Datos>( Ruta( personaje ) );\n\t\tif ( datos == null ) return DeFabrica( personaje );\n\n\t\tNormalizar( datos );\n\t\treturn datos;\n\t}\n\n\tstatic Datos DeFabrica( string personaje )\n\t{\n\t\tvar datos = PartiturasDeFabrica.Obtener( personaje );\n\t\tif ( datos != null ) Normalizar( datos );\n\t\treturn datos;\n\t}\n\n\tpublic static void Guardar( string personaje, Datos datos )\n\t{\n\t\tif ( string.IsNullOrEmpty( personaje ) || datos == null ) return;\n\n\t\tFileSystem.Data.CreateDirectory( Carpeta );\n\t\tFileSystem.Data.WriteJson( Ruta( personaje ), datos );\n\t}\n\n\t/// <summary> Vuelca los datos al componente vivo (copias, no referencias compartidas). </summary>\n\tpublic static void AplicarA( PartituraCaras partitura, Datos datos )\n\t{\n\t\tif ( !partitura.IsValid() || datos == null ) return;\n\n\t\tpartitura.LoopA = new List<int>( datos.LoopA );\n\t\tpartitura.LoopB = new List<int>( datos.LoopB );\n\t\tpartitura.HorrorA = new List<int>( datos.HorrorA );\n\t\tpartitura.HorrorB = new List<int>( datos.HorrorB );\n\t}\n\n\t/// <summary> Pistas siempre de 48 casilleros, venga como venga el JSON. </summary>\n\tstatic void Normalizar( Datos datos )\n\t{\n\t\tdatos.LoopA = Ajustar( datos.LoopA );\n\t\tdatos.LoopB = Ajustar( datos.LoopB );\n\t\tdatos.HorrorA = Ajustar( datos.HorrorA );\n\t\tdatos.HorrorB = Ajustar( datos.HorrorB );\n\t}\n\n\tstatic List<int> Ajustar( List<int> pista )\n\t{\n\t\tpista ??= new List<int>();\n\t\twhile ( pista.Count < PartituraCaras.Casilleros ) pista.Add( -1 );\n\t\tif ( pista.Count > PartituraCaras.Casilleros )\n\t\t\tpista.RemoveRange( PartituraCaras.Casilleros, pista.Count - PartituraCaras.Casilleros );\n\t\treturn pista;\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "TemblorCamara.cs",
            "FileName": "TemblorCamara.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\n\n/// <summary>\n/// [SBHBase] TEMBLOR DE C\u00c1MARA gen\u00e9rico (2026-08-10, pedido de H\u00e9ctor para\n/// los pisotones del Siren Head). Modelo de \"trauma\": los golpes suman\n/// trauma, el trauma decae solo, y mientras haya trauma la vista vibra\n/// (offset + roll aleatorios, curva cuadr\u00e1tica).\n///\n/// Es un ICameraModifier con el ORDEN M\u00c1S ALTO: corre al final de la cadena\n/// de c\u00e1mara del engine, sobre la vista YA calculada del frame \u2014 primera\n/// persona, posesi\u00f3n (\u00f3rbita), cine \u2014 as\u00ed que no hay realimentaci\u00f3n posible\n/// (la versi\u00f3n anterior mutaba la c\u00e1mara post-update y la \u00f3rbita de la\n/// posesi\u00f3n heredaba la sacudida: \"se buguea la c\u00e1mara\", 2026-08-10).\n///\n/// Cualquier sistema puede llamar <see cref=\"Sacudir\"/>: pisadas de gigante,\n/// explosiones, el tr\u00edpode cuando vuelva del taller...\n/// </summary>\npublic sealed class TemblorCamara : Component, ICameraModifier\n{\n\tstatic TemblorCamara _instancia;\n\tfloat _trauma;\n\n\t/// <summary> Suma un golpe de temblor (0.1 = apenas, 1 = terremoto). </summary>\n\tpublic static void Sacudir( Scene scene, float intensidad )\n\t{\n\t\tif ( intensidad <= 0f ) return;\n\t\tvar t = Asegurar( scene );\n\t\tif ( t.IsValid() )\n\t\t\tt._trauma = ( t._trauma + intensidad ).Clamp( 0f, 1.5f );\n\t}\n\n\t// Patr\u00f3n FadeHorror.Asegurar: un solo GameObject de servicio en la escena\n\tstatic TemblorCamara Asegurar( Scene scene )\n\t{\n\t\tif ( _instancia.IsValid() ) return _instancia;\n\t\tif ( scene == null ) return null;\n\n\t\tvar go = scene.CreateObject();\n\t\tgo.Name = \"sbh_temblor_camara\";\n\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;\n\t\t_instancia = go.Components.Create<TemblorCamara>();\n\t\treturn _instancia;\n\t}\n\n\tint ICameraModifier.CameraOrder => 9999; // el \u00faltimo de la cadena\n\n\tvoid ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )\n\t{\n\t\tif ( _trauma <= 0.001f ) { _trauma = 0f; return; }\n\n\t\tvar fuerza = _trauma * _trauma;\n\t\tview.Position += Vector3.Random * ( 6f * fuerza );\n\t\tview.Rotation *= Rotation.From(\n\t\t\tGame.Random.Float( -1.2f, 1.2f ) * fuerza,\n\t\t\tGame.Random.Float( -1.2f, 1.2f ) * fuerza,\n\t\t\tGame.Random.Float( -2.5f, 2.5f ) * fuerza );\n\n\t\t// Decae solo: un golpe de 0.4 dura ~un tercio de segundo\n\t\t_trauma = System.MathF.Max( 0f, _trauma - Time.Delta * 2.2f );\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "Code/PercepcionNextbot.cs",
            "FileName": "PercepcionNextbot.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System;\n\n/// <summary>\n/// Filtro de percepci\u00f3n por objetivo, para que los packs modifiquen los\n/// sentidos sin tocar la base (el sigilo de Toy Zombies: te ven seg\u00fan tu\n/// velocidad y tu linterna). Un componente del mismo GameObject lo\n/// implementa y PercepcionNextbot lo consulta en cada chequeo.\n/// </summary>\npublic interface IFiltroPercepcion\n{\n\t/// <summary>\n\t/// \u00bfPuede percibir a este objetivo a esta distancia? true = s\u00ed (aunque\n\t/// est\u00e9 fuera del cono: el ruido delata \u2014 la l\u00ednea de visi\u00f3n igual se\n\t/// exige), false = no (silencioso/invisible), null = sin opini\u00f3n (siguen\n\t/// las reglas normales de la base).\n\t/// </summary>\n\tbool? PuedeVer( IVida objetivo, float distancia );\n}\n\n/// <summary>\n/// [SBHBase] Sentidos del nextbot: vista en cono con l\u00ednea de visi\u00f3n real\n/// (las paredes bloquean) y o\u00eddo de corto alcance en cualquier direcci\u00f3n.\n/// Recuerda al objetivo un rato despu\u00e9s de perderlo de vista, guardando su\n/// \u00faltima posici\u00f3n conocida, y lo olvida pasado TiempoOlvido.\n/// Si un GameObject no tiene este componente, NextbotBrain vuelve al\n/// comportamiento omnisciente cl\u00e1sico.\n/// </summary>\npublic sealed class PercepcionNextbot : Component\n{\n\t// ---------- Configurable desde el Inspector ----------\n\t[Property, Description( \"Distancia m\u00e1xima de visi\u00f3n\" )]\n\tpublic float RangoVista { get; set; } = 1500f;\n\n\t[Property, Description( \"\u00c1ngulo total del cono de visi\u00f3n, en grados\" )]\n\tpublic float AnguloVista { get; set; } = 140f;\n\n\t[Property, Description( \"Distancia a la que siente enemigos aunque est\u00e9n fuera del cono de visi\u00f3n (una pared igual lo bloquea)\" )]\n\tpublic float RangoOido { get; set; } = 150f;\n\n\t[Property, Description( \"Altura de los ojos respecto al piso del nextbot\" )]\n\tpublic float AlturaOjos { get; set; } = 60f;\n\n\t[Property, Description( \"Segundos que recuerda al objetivo tras perderlo de vista\" )]\n\tpublic float TiempoOlvido { get; set; } = 5f;\n\n\t[Property, Description( \"Segundos entre escaneos de enemigos (rendimiento)\" )]\n\tpublic float IntervaloEscaneo { get; set; } = 0.2f;\n\n\t[Property, Description( \"Multiplicador del rango de visi\u00f3n mientras est\u00e1 en alerta (tras recibir da\u00f1o)\" )]\n\tpublic float MultiplicadorAlerta { get; set; } = 2f;\n\n\t[Property, Description( \"Segundos que dura la alerta tras recibir da\u00f1o (cada golpe la renueva)\" )]\n\tpublic float DuracionAlerta { get; set; } = 15f;\n\n\t// ---------- Estado que consumen otros componentes ----------\n\t/// <summary>\n\t/// Omnisciencia temporal: mientras est\u00e9 en true percibe a TODOS sus\n\t/// enemigos sin cono, sin paredes y sin l\u00edmite de distancia (el\n\t/// SetOmniscient de DRGBase). La prenden skills de pack \u2014 el grito de\n\t/// manada del Mono \u2014 y la apagan ellas mismas al terminar.\n\t/// </summary>\n\tpublic bool ModoOmnisciente { get; set; }\n\n\t/// <summary> Enemigo actual (percibido ahora o recordado). Null si no hay. </summary>\n\tpublic IVida ObjetivoActual { get; private set; }\n\n\t/// <summary> \u00bfEst\u00e1 percibiendo al objetivo AHORA MISMO? </summary>\n\tpublic bool ObjetivoVisible { get; private set; }\n\n\t/// <summary> \u00daltimo lugar donde percibi\u00f3 al objetivo. V\u00e1lido si ObjetivoActual no es null. </summary>\n\tpublic Vector3 UltimaPosicionConocida { get; private set; }\n\n\t/// <summary> Posici\u00f3n de los ojos, para traces de visi\u00f3n. </summary>\n\tpublic Vector3 Ojos => WorldPosition + Vector3.Up * AlturaOjos;\n\n\t/// <summary> \u00bfEst\u00e1 en alerta? (lo da\u00f1aron hace poco: ve m\u00e1s lejos mientras busca) </summary>\n\tpublic bool EnAlerta => _desdeAlerta < DuracionAlerta;\n\n\t/// <summary> Rango de visi\u00f3n vigente: el normal, o el ampliado si est\u00e1 en alerta. </summary>\n\tpublic float RangoVistaEfectivo => EnAlerta ? RangoVista * MultiplicadorAlerta : RangoVista;\n\n\tTimeSince _desdeEscaneo;\n\tTimeSince _desdeVisto;\n\tTimeSince _desdeAlerta;\n\tVida _vida;\n\tNextbotBrain _cerebro;\n\n\t// La IA de este bot est\u00e1 apagada, sea por el switch global o por la\n\t// suspensi\u00f3n individual del cerebro (tecla apunt\u00e1ndolo). Sin sentidos ni\n\t// memoria \u2014 y de rebote SaltoNextbot tampoco salta (bebe de ObjetivoActual)\n\tbool IaApagada => !SbhConVars.IaActivada\n\t\t|| (_cerebro.IsValid() && _cerebro.IaSuspendida);\n\n\tprotected override void OnStart()\n\t{\n\t\t_desdeEscaneo = 999f;\n\t\t_desdeVisto = 999f;\n\t\t_desdeAlerta = 999f;\n\t}\n\n\tprotected override void OnEnabled()\n\t{\n\t\t_vida = Components.Get<Vida>();\n\t\t_cerebro = Components.Get<NextbotBrain>();\n\t\tif ( _vida.IsValid() )\n\t\t\t_vida.AlRecibirDanio += AlSerDaniado;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tif ( _vida.IsValid() )\n\t\t\t_vida.AlRecibirDanio -= AlSerDaniado;\n\t}\n\n\t/// <summary>\n\t/// Venganza: al recibir da\u00f1o, fija como objetivo a quien lo hizo aunque\n\t/// est\u00e9 fuera de los sentidos (un francotirador por la espalda, p. ej.).\n\t/// Sabe D\u00d3NDE estaba el atacante, no necesariamente lo ve.\n\t/// </summary>\n\tvoid AlSerDaniado( float cantidad )\n\t{\n\t\tvar atacante = _vida?.UltimoAtacante;\n\t\tif ( !atacante.IsValid() || atacante == GameObject ) return;\n\n\t\t// El atacante puede ser el arma (hija del jugador): buscar hacia arriba\n\t\tvar objetivo = atacante.Components.Get<IVida>( FindMode.EverythingInSelfAndAncestors );\n\n\t\tif ( objetivo == null || objetivo.EstaMuerto ) return;\n\t\tif ( IaApagada ) return;\n\t\tif ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return; // notarget total, ni con balazos\n\n\t\t// Alerta: mientras busca al agresor ve m\u00e1s lejos; se calma sola si no lo encuentra\n\t\t_desdeAlerta = 0f;\n\n\t\tObjetivoActual = objetivo;\n\t\tObjetivoVisible = false;\n\t\tUltimaPosicionConocida = objetivo.WorldPosition;\n\t\t_desdeVisto = 0f;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( _desdeEscaneo < IntervaloEscaneo ) return;\n\t\t_desdeEscaneo = 0f;\n\n\t\tEscanear();\n\t}\n\n\tvoid Escanear()\n\t{\n\t\t// IA apagada (global o suspensi\u00f3n individual): sin sentidos ni memoria\n\t\tif ( IaApagada )\n\t\t{\n\t\t\tOlvidar();\n\t\t\treturn;\n\t\t}\n\n\t\t// Los muertos se olvidan al instante; los jugadores tambi\u00e9n si ahora los\n\t\t// ignoramos, y lo mismo quien sali\u00f3 del combate (posesi\u00f3n, cinem\u00e1ticas)\n\t\tif ( ObjetivoActual != null && (!ObjetivoActual.IsValid() || ObjetivoActual.EstaMuerto\n\t\t\t|| ObjetivoActual.FueraDeCombate\n\t\t\t|| (SbhConVars.IgnorarJugadores && ObjetivoActual.EsJugador)) )\n\t\t\tOlvidar();\n\n\t\t// Buscar el enemigo percibido m\u00e1s cercano\n\t\tvar miFaccion = Components.Get<Faccion>();\n\n\t\tIVida mejor = null;\n\t\tvar mejorDistancia = float.MaxValue;\n\n\t\tforeach ( var vida in Vidas.Todas( Scene ) )\n\t\t{\n\t\t\tif ( vida.GameObject == GameObject ) continue;\n\t\t\tif ( vida.EstaMuerto ) continue;\n\t\t\tif ( vida.FueraDeCombate ) continue;\n\t\t\tif ( SbhConVars.IgnorarJugadores && vida.EsJugador ) continue;\n\t\t\tif ( !Faccion.SonEnemigos( miFaccion, vida.GameObject.Components.Get<Faccion>() ) ) continue;\n\t\t\tif ( !Percibe( vida ) ) continue;\n\n\t\t\tvar distancia = vida.WorldPosition.Distance( WorldPosition );\n\t\t\tif ( distancia < mejorDistancia )\n\t\t\t{\n\t\t\t\tmejorDistancia = distancia;\n\t\t\t\tmejor = vida;\n\t\t\t}\n\t\t}\n\n\t\tif ( mejor != null )\n\t\t{\n\t\t\tObjetivoActual = mejor;\n\t\t\tObjetivoVisible = true;\n\t\t\tUltimaPosicionConocida = mejor.WorldPosition;\n\t\t\t_desdeVisto = 0f;\n\t\t\treturn;\n\t\t}\n\n\t\t// No percibe a nadie: mantener el recuerdo un rato y despu\u00e9s olvidar\n\t\tObjetivoVisible = false;\n\n\t\tif ( ObjetivoActual != null && _desdeVisto > TiempoOlvido )\n\t\t\tOlvidar();\n\t}\n\n\t/// <summary>\n\t/// Le marca un objetivo desde afuera, aunque no lo haya percibido: el\n\t/// equivalente al SpotEntity/SetEnemy de DRGBase. Lo usan las skills de\n\t/// manada (el grito del Mono se\u00f1ala a la v\u00edctima para todo el pack).\n\t/// Entra como recuerdo \u2014 el escaneo siguiente decide si adem\u00e1s lo ve.\n\t/// </summary>\n\tpublic void Marcar( IVida objetivo )\n\t{\n\t\tif ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;\n\t\tif ( IaApagada ) return;\n\t\tif ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return;\n\n\t\tObjetivoActual = objetivo;\n\t\tObjetivoVisible = false;\n\t\tUltimaPosicionConocida = objetivo.WorldPosition;\n\t\t_desdeVisto = 0f;\n\t}\n\n\t/// <summary> \u00bfEste enemigo entra por alguno de los sentidos? </summary>\n\tpublic bool Percibe( IVida objetivo )\n\t{\n\t\t// Omnisciente: lo ve est\u00e9 donde est\u00e9, incluso atr\u00e1s y tras una pared\n\t\t// (pisa tambi\u00e9n al filtro: el grito de manada anula el sigilo).\n\t\t// Por skill (GritoManada) o por el interruptor GLOBAL de la base.\n\t\tif ( ModoOmnisciente || SbhConVars.Omnisciencia ) return true;\n\n\t\tvar distancia = objetivo.WorldPosition.Distance( WorldPosition );\n\n\t\t// Filtro del pack (sigilo): false = no lo percibe por m\u00e1s cerca que\n\t\t// est\u00e9; true = percibido aunque est\u00e9 fuera del cono (el ruido delata\n\t\t// la direcci\u00f3n), pero una pared igual lo esconde\n\t\tvar filtro = Components.Get<IFiltroPercepcion>();\n\t\tif ( filtro != null )\n\t\t{\n\t\t\tvar veredicto = filtro.PuedeVer( objetivo, distancia );\n\t\t\tif ( veredicto == false ) return false;\n\t\t\tif ( veredicto == true )\n\t\t\t\treturn distancia <= RangoVistaEfectivo && TieneLineaDeVision( objetivo );\n\t\t}\n\n\t\t// O\u00eddo: corto alcance sin importar el \u00e1ngulo, pero las paredes\n\t\t// S\u00cd bloquean (si no, es un radar y se siente omnisciente)\n\t\tif ( distancia <= RangoOido )\n\t\t\treturn TieneLineaDeVision( objetivo );\n\n\t\t// Vista: alcance + cono + l\u00ednea de visi\u00f3n libre\n\t\tif ( distancia > RangoVistaEfectivo )\n\t\t\treturn false;\n\n\t\tvar direccion = (objetivo.WorldPosition - WorldPosition).WithZ( 0 );\n\t\tif ( direccion.Length > 1f )\n\t\t{\n\t\t\tvar cosLimite = MathF.Cos( AnguloVista * 0.5f * MathF.PI / 180f );\n\t\t\tvar cosAngulo = Vector3.Dot( WorldRotation.Forward.WithZ( 0 ).Normal, direccion.Normal );\n\t\t\tif ( cosAngulo < cosLimite )\n\t\t\t\treturn false;\n\t\t}\n\n\t\treturn TieneLineaDeVision( objetivo );\n\t}\n\n\tbool TieneLineaDeVision( IVida objetivo )\n\t{\n\t\t// Apuntar al torso del objetivo, no a sus pies\n\t\tvar puntoObjetivo = objetivo.WorldPosition + Vector3.Up * 40f;\n\n\t\tvar trace = Scene.Trace.Ray( Ojos, puntoObjetivo )\n\t\t\t.IgnoreGameObjectHierarchy( GameObject )\n\t\t\t.IgnoreGameObjectHierarchy( objetivo.GameObject )\n\t\t\t.Run();\n\n\t\t// Si el rayo no choc\u00f3 con nada en el camino, lo ve\n\t\treturn !trace.Hit;\n\t}\n\n\tpublic void Olvidar()\n\t{\n\t\tObjetivoActual = null;\n\t\tObjetivoVisible = false;\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "PercepcionNextbot.cs",
            "FileName": "PercepcionNextbot.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System;\n\n/// <summary>\n/// Filtro de percepci\u00f3n por objetivo, para que los packs modifiquen los\n/// sentidos sin tocar la base (el sigilo de Toy Zombies: te ven seg\u00fan tu\n/// velocidad y tu linterna). Un componente del mismo GameObject lo\n/// implementa y PercepcionNextbot lo consulta en cada chequeo.\n/// </summary>\npublic interface IFiltroPercepcion\n{\n\t/// <summary>\n\t/// \u00bfPuede percibir a este objetivo a esta distancia? true = s\u00ed (aunque\n\t/// est\u00e9 fuera del cono: el ruido delata \u2014 la l\u00ednea de visi\u00f3n igual se\n\t/// exige), false = no (silencioso/invisible), null = sin opini\u00f3n (siguen\n\t/// las reglas normales de la base).\n\t/// </summary>\n\tbool? PuedeVer( IVida objetivo, float distancia );\n}\n\n/// <summary>\n/// [SBHBase] Sentidos del nextbot: vista en cono con l\u00ednea de visi\u00f3n real\n/// (las paredes bloquean) y o\u00eddo de corto alcance en cualquier direcci\u00f3n.\n/// Recuerda al objetivo un rato despu\u00e9s de perderlo de vista, guardando su\n/// \u00faltima posici\u00f3n conocida, y lo olvida pasado TiempoOlvido.\n/// Si un GameObject no tiene este componente, NextbotBrain vuelve al\n/// comportamiento omnisciente cl\u00e1sico.\n/// </summary>\npublic sealed class PercepcionNextbot : Component\n{\n\t// ---------- Configurable desde el Inspector ----------\n\t[Property, Description( \"Distancia m\u00e1xima de visi\u00f3n\" )]\n\tpublic float RangoVista { get; set; } = 1500f;\n\n\t[Property, Description( \"\u00c1ngulo total del cono de visi\u00f3n, en grados\" )]\n\tpublic float AnguloVista { get; set; } = 140f;\n\n\t[Property, Description( \"Distancia a la que siente enemigos aunque est\u00e9n fuera del cono de visi\u00f3n (una pared igual lo bloquea)\" )]\n\tpublic float RangoOido { get; set; } = 150f;\n\n\t[Property, Description( \"Altura de los ojos respecto al piso del nextbot\" )]\n\tpublic float AlturaOjos { get; set; } = 60f;\n\n\t[Property, Description( \"Segundos que recuerda al objetivo tras perderlo de vista\" )]\n\tpublic float TiempoOlvido { get; set; } = 5f;\n\n\t[Property, Description( \"Segundos entre escaneos de enemigos (rendimiento)\" )]\n\tpublic float IntervaloEscaneo { get; set; } = 0.2f;\n\n\t[Property, Description( \"Multiplicador del rango de visi\u00f3n mientras est\u00e1 en alerta (tras recibir da\u00f1o)\" )]\n\tpublic float MultiplicadorAlerta { get; set; } = 2f;\n\n\t[Property, Description( \"Segundos que dura la alerta tras recibir da\u00f1o (cada golpe la renueva)\" )]\n\tpublic float DuracionAlerta { get; set; } = 15f;\n\n\t// ---------- Estado que consumen otros componentes ----------\n\t/// <summary>\n\t/// Omnisciencia temporal: mientras est\u00e9 en true percibe a TODOS sus\n\t/// enemigos sin cono, sin paredes y sin l\u00edmite de distancia (el\n\t/// SetOmniscient de DRGBase). La prenden skills de pack \u2014 el grito de\n\t/// manada del Mono \u2014 y la apagan ellas mismas al terminar.\n\t/// </summary>\n\tpublic bool ModoOmnisciente { get; set; }\n\n\t/// <summary> Enemigo actual (percibido ahora o recordado). Null si no hay. </summary>\n\tpublic IVida ObjetivoActual { get; private set; }\n\n\t/// <summary> \u00bfEst\u00e1 percibiendo al objetivo AHORA MISMO? </summary>\n\tpublic bool ObjetivoVisible { get; private set; }\n\n\t/// <summary> \u00daltimo lugar donde percibi\u00f3 al objetivo. V\u00e1lido si ObjetivoActual no es null. </summary>\n\tpublic Vector3 UltimaPosicionConocida { get; private set; }\n\n\t/// <summary> Posici\u00f3n de los ojos, para traces de visi\u00f3n. </summary>\n\tpublic Vector3 Ojos => WorldPosition + Vector3.Up * AlturaOjos;\n\n\t/// <summary> \u00bfEst\u00e1 en alerta? (lo da\u00f1aron hace poco: ve m\u00e1s lejos mientras busca) </summary>\n\tpublic bool EnAlerta => _desdeAlerta < DuracionAlerta;\n\n\t/// <summary> Rango de visi\u00f3n vigente: el normal, o el ampliado si est\u00e1 en alerta. </summary>\n\tpublic float RangoVistaEfectivo => EnAlerta ? RangoVista * MultiplicadorAlerta : RangoVista;\n\n\tTimeSince _desdeEscaneo;\n\tTimeSince _desdeVisto;\n\tTimeSince _desdeAlerta;\n\tVida _vida;\n\tNextbotBrain _cerebro;\n\n\t// La IA de este bot est\u00e1 apagada, sea por el switch global o por la\n\t// suspensi\u00f3n individual del cerebro (tecla apunt\u00e1ndolo). Sin sentidos ni\n\t// memoria \u2014 y de rebote SaltoNextbot tampoco salta (bebe de ObjetivoActual)\n\tbool IaApagada => !SbhConVars.IaActivada\n\t\t|| (_cerebro.IsValid() && _cerebro.IaSuspendida);\n\n\tprotected override void OnStart()\n\t{\n\t\t_desdeEscaneo = 999f;\n\t\t_desdeVisto = 999f;\n\t\t_desdeAlerta = 999f;\n\t}\n\n\tprotected override void OnEnabled()\n\t{\n\t\t_vida = Components.Get<Vida>();\n\t\t_cerebro = Components.Get<NextbotBrain>();\n\t\tif ( _vida.IsValid() )\n\t\t\t_vida.AlRecibirDanio += AlSerDaniado;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tif ( _vida.IsValid() )\n\t\t\t_vida.AlRecibirDanio -= AlSerDaniado;\n\t}\n\n\t/// <summary>\n\t/// Venganza: al recibir da\u00f1o, fija como objetivo a quien lo hizo aunque\n\t/// est\u00e9 fuera de los sentidos (un francotirador por la espalda, p. ej.).\n\t/// Sabe D\u00d3NDE estaba el atacante, no necesariamente lo ve.\n\t/// </summary>\n\tvoid AlSerDaniado( float cantidad )\n\t{\n\t\tvar atacante = _vida?.UltimoAtacante;\n\t\tif ( !atacante.IsValid() || atacante == GameObject ) return;\n\n\t\t// El atacante puede ser el arma (hija del jugador): buscar hacia arriba\n\t\tvar objetivo = atacante.Components.Get<IVida>( FindMode.EverythingInSelfAndAncestors );\n\n\t\tif ( objetivo == null || objetivo.EstaMuerto ) return;\n\t\tif ( IaApagada ) return;\n\t\tif ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return; // notarget total, ni con balazos\n\n\t\t// Alerta: mientras busca al agresor ve m\u00e1s lejos; se calma sola si no lo encuentra\n\t\t_desdeAlerta = 0f;\n\n\t\tObjetivoActual = objetivo;\n\t\tObjetivoVisible = false;\n\t\tUltimaPosicionConocida = objetivo.WorldPosition;\n\t\t_desdeVisto = 0f;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( _desdeEscaneo < IntervaloEscaneo ) return;\n\t\t_desdeEscaneo = 0f;\n\n\t\tEscanear();\n\t}\n\n\tvoid Escanear()\n\t{\n\t\t// IA apagada (global o suspensi\u00f3n individual): sin sentidos ni memoria\n\t\tif ( IaApagada )\n\t\t{\n\t\t\tOlvidar();\n\t\t\treturn;\n\t\t}\n\n\t\t// Los muertos se olvidan al instante; los jugadores tambi\u00e9n si ahora los\n\t\t// ignoramos, y lo mismo quien sali\u00f3 del combate (posesi\u00f3n, cinem\u00e1ticas)\n\t\tif ( ObjetivoActual != null && (!ObjetivoActual.IsValid() || ObjetivoActual.EstaMuerto\n\t\t\t|| ObjetivoActual.FueraDeCombate\n\t\t\t|| (SbhConVars.IgnorarJugadores && ObjetivoActual.EsJugador)) )\n\t\t\tOlvidar();\n\n\t\t// Buscar el enemigo percibido m\u00e1s cercano\n\t\tvar miFaccion = Components.Get<Faccion>();\n\n\t\tIVida mejor = null;\n\t\tvar mejorDistancia = float.MaxValue;\n\n\t\tforeach ( var vida in Vidas.Todas( Scene ) )\n\t\t{\n\t\t\tif ( vida.GameObject == GameObject ) continue;\n\t\t\tif ( vida.EstaMuerto ) continue;\n\t\t\tif ( vida.FueraDeCombate ) continue;\n\t\t\tif ( SbhConVars.IgnorarJugadores && vida.EsJugador ) continue;\n\t\t\tif ( !Faccion.SonEnemigos( miFaccion, vida.GameObject.Components.Get<Faccion>() ) ) continue;\n\t\t\tif ( !Percibe( vida ) ) continue;\n\n\t\t\tvar distancia = vida.WorldPosition.Distance( WorldPosition );\n\t\t\tif ( distancia < mejorDistancia )\n\t\t\t{\n\t\t\t\tmejorDistancia = distancia;\n\t\t\t\tmejor = vida;\n\t\t\t}\n\t\t}\n\n\t\tif ( mejor != null )\n\t\t{\n\t\t\tObjetivoActual = mejor;\n\t\t\tObjetivoVisible = true;\n\t\t\tUltimaPosicionConocida = mejor.WorldPosition;\n\t\t\t_desdeVisto = 0f;\n\t\t\treturn;\n\t\t}\n\n\t\t// No percibe a nadie: mantener el recuerdo un rato y despu\u00e9s olvidar\n\t\tObjetivoVisible = false;\n\n\t\tif ( ObjetivoActual != null && _desdeVisto > TiempoOlvido )\n\t\t\tOlvidar();\n\t}\n\n\t/// <summary>\n\t/// Le marca un objetivo desde afuera, aunque no lo haya percibido: el\n\t/// equivalente al SpotEntity/SetEnemy de DRGBase. Lo usan las skills de\n\t/// manada (el grito del Mono se\u00f1ala a la v\u00edctima para todo el pack).\n\t/// Entra como recuerdo \u2014 el escaneo siguiente decide si adem\u00e1s lo ve.\n\t/// </summary>\n\tpublic void Marcar( IVida objetivo )\n\t{\n\t\tif ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;\n\t\tif ( IaApagada ) return;\n\t\tif ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return;\n\n\t\tObjetivoActual = objetivo;\n\t\tObjetivoVisible = false;\n\t\tUltimaPosicionConocida = objetivo.WorldPosition;\n\t\t_desdeVisto = 0f;\n\t}\n\n\t/// <summary> \u00bfEste enemigo entra por alguno de los sentidos? </summary>\n\tpublic bool Percibe( IVida objetivo )\n\t{\n\t\t// Omnisciente: lo ve est\u00e9 donde est\u00e9, incluso atr\u00e1s y tras una pared\n\t\t// (pisa tambi\u00e9n al filtro: el grito de manada anula el sigilo).\n\t\t// Por skill (GritoManada) o por el interruptor GLOBAL de la base.\n\t\tif ( ModoOmnisciente || SbhConVars.Omnisciencia ) return true;\n\n\t\tvar distancia = objetivo.WorldPosition.Distance( WorldPosition );\n\n\t\t// Filtro del pack (sigilo): false = no lo percibe por m\u00e1s cerca que\n\t\t// est\u00e9; true = percibido aunque est\u00e9 fuera del cono (el ruido delata\n\t\t// la direcci\u00f3n), pero una pared igual lo esconde\n\t\tvar filtro = Components.Get<IFiltroPercepcion>();\n\t\tif ( filtro != null )\n\t\t{\n\t\t\tvar veredicto = filtro.PuedeVer( objetivo, distancia );\n\t\t\tif ( veredicto == false ) return false;\n\t\t\tif ( veredicto == true )\n\t\t\t\treturn distancia <= RangoVistaEfectivo && TieneLineaDeVision( objetivo );\n\t\t}\n\n\t\t// O\u00eddo: corto alcance sin importar el \u00e1ngulo, pero las paredes\n\t\t// S\u00cd bloquean (si no, es un radar y se siente omnisciente)\n\t\tif ( distancia <= RangoOido )\n\t\t\treturn TieneLineaDeVision( objetivo );\n\n\t\t// Vista: alcance + cono + l\u00ednea de visi\u00f3n libre\n\t\tif ( distancia > RangoVistaEfectivo )\n\t\t\treturn false;\n\n\t\tvar direccion = (objetivo.WorldPosition - WorldPosition).WithZ( 0 );\n\t\tif ( direccion.Length > 1f )\n\t\t{\n\t\t\tvar cosLimite = MathF.Cos( AnguloVista * 0.5f * MathF.PI / 180f );\n\t\t\tvar cosAngulo = Vector3.Dot( WorldRotation.Forward.WithZ( 0 ).Normal, direccion.Normal );\n\t\t\tif ( cosAngulo < cosLimite )\n\t\t\t\treturn false;\n\t\t}\n\n\t\treturn TieneLineaDeVision( objetivo );\n\t}\n\n\tbool TieneLineaDeVision( IVida objetivo )\n\t{\n\t\t// Apuntar al torso del objetivo, no a sus pies\n\t\tvar puntoObjetivo = objetivo.WorldPosition + Vector3.Up * 40f;\n\n\t\tvar trace = Scene.Trace.Ray( Ojos, puntoObjetivo )\n\t\t\t.IgnoreGameObjectHierarchy( GameObject )\n\t\t\t.IgnoreGameObjectHierarchy( objetivo.GameObject )\n\t\t\t.Run();\n\n\t\t// Si el rayo no choc\u00f3 con nada en el camino, lo ve\n\t\treturn !trace.Hit;\n\t}\n\n\tpublic void Olvidar()\n\t{\n\t\tObjetivoActual = null;\n\t\tObjetivoVisible = false;\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "RecetaFusion.cs",
            "FileName": "RecetaFusion.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System.Collections.Generic;\n\n/// <summary>\n/// [SBHBase] Receta de fusi\u00f3n: N ingredientes (por nombre visible en escena,\n/// con cantidad) \u2192 una entidad resultado. Los ingredientes pueden ser\n/// nextbots (su Print Name: \"Raddy\") o props (el nombre con el que spawnean\n/// del men\u00fa Q) \u2014 la tool Merge no distingue. Las recetas son DATOS del pack:\n/// cada pack trae sus archivos .fusion en Assets y la tool las levanta sola\n/// de ResourceLibrary. El mecanismo vive en la base; los personajes, no.\n/// </summary>\n[AssetType( Name = \"SBHBase Fusion Recipe\", Extension = \"fusion\", Category = \"SBHBase\" )]\npublic class RecetaFusion : GameResource\n{\n\t[Property, Description( \"Qu\u00e9 hace falta juntar: nombre visible y cantidad de cada ingrediente\" )]\n\tpublic List<IngredienteFusion> Ingredientes { get; set; } = new();\n\n\t[Property, Description( \"Prefab que aparece al completar la receta\" )]\n\tpublic PrefabFile Resultado { get; set; }\n\n\t[Property, Description( \"Sonido al fusionar (opcional)\" )]\n\tpublic SoundEvent Sonido { get; set; }\n}\n\n/// <summary> Un ingrediente de la receta: nombre visible + cu\u00e1ntos. </summary>\npublic class IngredienteFusion\n{\n\t[Property, Description( \"Identidad del ingrediente: Print Name del bot, o nombre del modelo del prop (\\\"watermelon\\\"); no distingue may\u00fasculas\" )]\n\tpublic string Nombre { get; set; } = \"\";\n\n\t[Property, Description( \"Cu\u00e1ntos hacen falta\" )]\n\tpublic int Cantidad { get; set; } = 1;\n\n\t[Property, Description( \"Ruta de asset para la miniatura del recetario (vac\u00edo = se busca el .sent por nombre; para props, la ruta del .vmdl)\" )]\n\tpublic string Icono { get; set; }\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "SaltoNextbot.cs",
            "FileName": "SaltoNextbot.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System;\n\n/// <summary>\n/// [SBHBase] Salto de nextbot: cuando persigue y el objetivo est\u00e1 a otro\n/// nivel \u2014 arriba de un contenedor, abajo de un puente \u2014 o el camino dej\u00f3\n/// de avanzar, se lanza hacia \u00e9l con una par\u00e1bola bal\u00edstica. Sin miedo a\n/// caer: si el objetivo est\u00e1 abajo, salta desde el borde directamente.\n/// Durante el vuelo toma el control del movimiento (apaga NavMeshAgent y\n/// NextbotBrain) y lo devuelve al aterrizar, reencajando el agente en el\n/// navmesh. Usa el objetivo de PercepcionNextbot; sin ella no salta.\n/// </summary>\npublic sealed class SaltoNextbot : Component\n{\n\t[RequireComponent] NavMeshAgent Agent { get; set; }\n\t[RequireComponent] NextbotBrain Cerebro { get; set; }\n\n\t[Property, Description( \"Desnivel m\u00ednimo con el objetivo para plantearse saltar\" )]\n\tpublic float DesnivelMinimo { get; set; } = 80f;\n\n\t[Property, Description( \"Distancia horizontal m\u00e1xima que cubre un salto\" )]\n\tpublic float DistanciaMaxima { get; set; } = 500f;\n\n\t[Property, Description( \"Altura extra del arco por encima del punto m\u00e1s alto del salto\" )]\n\tpublic float AlturaArco { get; set; } = 60f;\n\n\t[Property, Description( \"Gravedad durante el vuelo\" )]\n\tpublic float Gravedad { get; set; } = 800f;\n\n\t[Property, Description( \"Segundos m\u00ednimos entre saltos\" )]\n\tpublic float Cooldown { get; set; } = 2f;\n\n\t[Property, Description( \"Si persiguiendo no avanza durante este tiempo (seg), salta hacia el objetivo aunque no haya desnivel\" )]\n\tpublic float TiempoAtascado { get; set; } = 1.5f;\n\n\t[Property, Description( \"Distancia de contacto para conectar el cabezazo en pleno vuelo (0 = sin golpe a\u00e9reo)\" )]\n\tpublic float RangoGolpeAereo { get; set; } = 60f;\n\n\t[Property, Description( \"Par\u00e1metro bool del animgraph para la anim de ca\u00edda durante el vuelo (vac\u00edo = sin anim)\" )]\n\tpublic string ParametroCaida { get; set; } = \"b_fall\";\n\n\t[Property, Description( \"Velocidad vertical del salto CUANDO LO POSEE UN JUGADOR (la IA usa el arco bal\u00edstico de arriba). 330 = salto de persona; un coloso necesita mucho m\u00e1s\" )]\n\tpublic float FuerzaSaltoPosesion { get; set; } = 330f;\n\n\t// --- Multiplicador global en vivo (sbh.salto.mult) ---\n\t// Escala arco y alcance sin tocar los prefabs: se tunea jugando y despu\u00e9s\n\t// se hornean los valores por personaje.\n\tfloat Mult => System.MathF.Max( SbhConVars.SaltoMultiplicador, 0.05f );\n\tfloat DistanciaEfectiva => DistanciaMaxima * Mult;\n\tfloat ArcoEfectivo => AlturaArco * Mult;\n\n\t/// <summary> La fuerza de salto para la posesi\u00f3n, ya con el multiplicador global. </summary>\n\tpublic float FuerzaSaltoPosesionEfectiva => FuerzaSaltoPosesion * Mult;\n\n\t/// <summary> \u00bfEst\u00e1 en el aire ahora mismo? (para que otros componentes lo consulten) </summary>\n\tpublic bool Volando { get; private set; }\n\n\tPercepcionNextbot _percepcion;\n\tVida _vida;\n\tVector3 _velocidad;\n\tTimeSince _desdeSalto;\n\tTimeSince _desdeDespegue;\n\tVector3 _posicionAnterior;\n\tTimeSince _desdeAvance;\n\n\tprotected override void OnStart()\n\t{\n\t\t_percepcion = Components.Get<PercepcionNextbot>();\n\t\t_vida = Components.Get<Vida>();\n\t\t_desdeSalto = 999f;\n\t\t_posicionAnterior = WorldPosition;\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\tif ( Volando )\n\t\t\tAterrizar( WorldPosition );\n\t}\n\n\tprotected override void OnFixedUpdate()\n\t{\n\t\tif ( Volando )\n\t\t{\n\t\t\tVolar();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( _vida.IsValid() && _vida.EstaMuerto ) return;\n\t\tif ( _desdeSalto < Cooldown ) return;\n\n\t\tvar destino = BuscarDestino();\n\t\tif ( destino == null )\n\t\t{\n\t\t\t_posicionAnterior = WorldPosition;\n\t\t\t_desdeAvance = 0f;\n\t\t\treturn;\n\t\t}\n\n\t\t// Detecci\u00f3n de atasco: \u00bfavanzamos algo desde la \u00faltima vez?\n\t\tif ( WorldPosition.Distance( _posicionAnterior ) > 30f )\n\t\t{\n\t\t\t_posicionAnterior = WorldPosition;\n\t\t\t_desdeAvance = 0f;\n\t\t}\n\n\t\tvar desnivel = destino.Value.z - WorldPosition.z;\n\t\tvar distanciaH = (destino.Value - WorldPosition).WithZ( 0 ).Length;\n\n\t\tif ( distanciaH > DistanciaEfectiva ) return;\n\n\t\tvar hayDesnivel = MathF.Abs( desnivel ) > DesnivelMinimo;\n\t\tvar atascado = _desdeAvance > TiempoAtascado;\n\n\t\tif ( !hayDesnivel && !atascado ) return;\n\t\tif ( !hayDesnivel && distanciaH < 60f ) return; // ya est\u00e1 encima, no hace falta\n\n\t\t// \u00bfEl arco CABE? Con el objetivo arriba de un techo/losa, el bot de\n\t\t// abajo saltaba una y otra vez contra el cielorraso queriendo\n\t\t// traspasarlo. Si algo del mundo tapa la vertical hasta el \u00e1pice del\n\t\t// salto, no hay salto \u2014 se sigue por navmesh como cualquier hijo de\n\t\t// vecino (y el pr\u00f3ximo intento ser\u00e1 desde otro lugar).\n\t\tvar apice = MathF.Max( WorldPosition.z, destino.Value.z ) + ArcoEfectivo;\n\t\tvar alturaVuelo = apice - WorldPosition.z;\n\t\tif ( alturaVuelo > 24f )\n\t\t{\n\t\t\tvar techo = Scene.Trace\n\t\t\t\t.Ray( WorldPosition + Vector3.Up * 24f, WorldPosition + Vector3.Up * alturaVuelo )\n\t\t\t\t.Radius( 14f )\n\t\t\t\t.IgnoreGameObjectHierarchy( GameObject )\n\t\t\t\t.WithoutTags( \"sbh_nextbot\", \"player\" )\n\t\t\t\t.Run();\n\n\t\t\tif ( techo.Hit ) return; // hay techo: el salto no cabe\n\t\t}\n\n\t\tSaltar( destino.Value );\n\t}\n\n\t/// <summary> Hacia d\u00f3nde saltar, o null si no hay persecuci\u00f3n activa. </summary>\n\tVector3? BuscarDestino()\n\t{\n\t\tif ( !_percepcion.IsValid() || !_percepcion.Enabled ) return null;\n\n\t\tvar objetivo = _percepcion.ObjetivoActual;\n\t\tif ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return null;\n\n\t\treturn _percepcion.ObjetivoVisible\n\t\t\t? objetivo.WorldPosition\n\t\t\t: _percepcion.UltimaPosicionConocida;\n\t}\n\n\tvoid Saltar( Vector3 destino )\n\t{\n\t\tvar origen = WorldPosition;\n\n\t\t// Bal\u00edstica: subir hasta AlturaArco por encima del punto m\u00e1s alto y\n\t\t// caer justo en el destino. vz por energ\u00eda, tiempo por cinem\u00e1tica.\n\t\tvar apex = MathF.Max( 0f, destino.z - origen.z ) + ArcoEfectivo;\n\t\tvar vz = MathF.Sqrt( 2f * Gravedad * apex );\n\n\t\tvar raiz = vz * vz - 2f * Gravedad * (destino.z - origen.z);\n\t\tif ( raiz < 0f ) return;\n\n\t\tvar tiempo = (vz + MathF.Sqrt( raiz )) / Gravedad;\n\t\tif ( tiempo < 0.1f ) return;\n\n\t\tvar horizontal = (destino - origen).WithZ( 0 );\n\t\t_velocidad = horizontal / tiempo + Vector3.Up * vz;\n\n\t\tif ( horizontal.Length > 1f )\n\t\t\tWorldRotation = Rotation.LookAt( horizontal.Normal, Vector3.Up );\n\n\t\tAgent.Enabled = false;\n\t\tCerebro.Enabled = false;\n\n\t\t// En el aire: anim de ca\u00edda (la misma que usa la physgun)\n\t\tif ( !string.IsNullOrEmpty( ParametroCaida ) )\n\t\t\tComponents.Get<SkinnedModelRenderer>()?.Set( ParametroCaida, true );\n\n\t\tVolando = true;\n\t\t_desdeDespegue = 0f;\n\t\t_desdeSalto = 0f;\n\t}\n\n\tvoid Volar()\n\t{\n\t\tvar dt = Time.Delta;\n\t\t_velocidad += Vector3.Down * Gravedad * dt;\n\n\t\t// Avanzar con una esfera a la altura del torso para chocar con el mundo\n\t\tvar desde = WorldPosition + Vector3.Up * 24f;\n\t\tvar hasta = desde + _velocidad * dt;\n\n\t\tvar trace = Scene.Trace.Ray( desde, hasta )\n\t\t\t.Radius( 14f )\n\t\t\t.IgnoreGameObjectHierarchy( GameObject )\n\t\t\t.WithoutTags( \"sbh_nextbot\" ) // otro nextbot no es piso: atravesarlo, no aterrizar encima\n\t\t\t.Run();\n\n\t\tif ( trace.Hit )\n\t\t{\n\t\t\t// Piso (o algo pisable) mientras cae: aterrizar\n\t\t\tif ( _velocidad.z <= 0f || trace.Normal.z > 0.5f )\n\t\t\t{\n\t\t\t\tAterrizar( trace.EndPosition - Vector3.Up * 24f );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Pared en plena subida: cortar el avance y dejar que caiga\n\t\t\t_velocidad = _velocidad.WithX( 0f ).WithY( 0f );\n\t\t\tWorldPosition = trace.EndPosition - Vector3.Up * 24f;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tWorldPosition = hasta - Vector3.Up * 24f;\n\t\t}\n\n\t\t// Cabezazo en vuelo: si el arco pasa por encima del objetivo, conecta\n\t\tIntentarGolpeAereo();\n\n\t\t// Seguridad: nunca volar para siempre (agujeros, ca\u00eddas al vac\u00edo)\n\t\tif ( _desdeDespegue > 4f )\n\t\t\tAterrizar( WorldPosition );\n\t}\n\n\t/// <summary>\n\t/// El pounce: en pleno vuelo, si el objetivo queda a distancia de cuerpo,\n\t/// el golpe conecta ah\u00ed mismo \u2014 sin retardo, el contacto ES el impacto.\n\t/// El cooldown de ataque del cerebro evita golpear dos veces por salto.\n\t/// </summary>\n\tvoid IntentarGolpeAereo()\n\t{\n\t\tif ( RangoGolpeAereo <= 0f || !Cerebro.IsValid() ) return;\n\n\t\tvar objetivo = _percepcion.IsValid() ? _percepcion.ObjetivoActual : null;\n\t\tif ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;\n\n\t\t// Medir desde el torso en vuelo contra el torso del objetivo\n\t\tvar torso = WorldPosition + Vector3.Up * 24f;\n\t\tvar torsoObjetivo = objetivo.WorldPosition + Vector3.Up * 32f;\n\n\t\tif ( torso.Distance( torsoObjetivo ) > RangoGolpeAereo ) return;\n\n\t\tCerebro.GolpeDirecto( objetivo );\n\t}\n\n\tvoid Aterrizar( Vector3 posicion )\n\t{\n\t\tVolando = false;\n\t\tWorldPosition = posicion;\n\n\t\tif ( !string.IsNullOrEmpty( ParametroCaida ) )\n\t\t\tComponents.Get<SkinnedModelRenderer>()?.Set( ParametroCaida, false );\n\n\t\t// Si muri\u00f3 en el aire, MuerteNextbot ya apag\u00f3 la IA: no revivirla\n\t\tif ( _vida.IsValid() && _vida.EstaMuerto ) return;\n\n\t\t// Pisada de gigante: el aterrizaje retumba (m\u00e1s fuerte que el paso)\n\t\tComponents.Get<PisadaGigante>()?.AlAterrizar();\n\n\t\tif ( Agent.IsValid() )\n\t\t{\n\t\t\tAgent.Enabled = true;\n\t\t\tAgent.SetAgentPosition( posicion );\n\t\t}\n\n\t\tif ( Cerebro.IsValid() )\n\t\t\tCerebro.Enabled = true;\n\t}\n}\n"
        },
        {
            "Ident": "ksbravo.sbhbase",
            "Path": "CabezaNextbot.cs",
            "FileName": "CabezaNextbot.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342288,
            "Code": "namespace SBH;\n\nusing Sandbox;\nusing System;\n\n/// <summary>\n/// [SBHBase] La cabeza del nextbot sigue al objetivo, estilo DRGBase.\n/// Alimenta el nodo Look At del animgraph compartido (sbh_nextbot.vanmgrph)\n/// v\u00eda <see cref=\"SkinnedModelRenderer.SetLookDirection\"/>: escribe el\n/// par\u00e1metro vector (direcci\u00f3n de mirada) y su peso, y el engine gira la\n/// cadena de huesos LookChain del vmdl ENCIMA de la animaci\u00f3n viva \u2014 la\n/// cabeza conserva el rebote de correr mientras mira al objetivo, sin\n/// bone-overrides ni anclas (el enfoque anterior peleaba con el snapshot\n/// de huesos del engine y qued\u00f3 descartado).\n/// Es el equivalente del autoplay blend del QC de GMod.\n/// Usa el objetivo de PercepcionNextbot; sin ese componente no hace nada.\n/// Requiere que el modelo tenga animgraph con el par\u00e1metro y una LookChain;\n/// si no los tiene, SetLookDirection simplemente no hace nada (inofensivo).\n/// </summary>\npublic sealed class CabezaNextbot : Component\n{\n\t[RequireComponent] SkinnedModelRenderer Renderer { get; set; }\n\n\t[Property, Description( \"Giro horizontal m\u00e1ximo hacia cada lado, en grados\" )]\n\tpublic float LimiteYaw { get; set; } = 70f;\n\n\t[Property, Description( \"Inclinaci\u00f3n vertical m\u00e1xima (arriba/abajo), en grados\" )]\n\tpublic float LimitePitch { get; set; } = 35f;\n\n\t[Property, Description( \"Velocidad de seguimiento (m\u00e1s alto = m\u00e1s brusco)\" )]\n\tpublic float Velocidad { get; set; } = 8f;\n\n\t[Property, Description( \"Altura sobre el origen del objetivo a la que mira (sus ojos)\" )]\n\tpublic float AlturaMirada { get; set; } = 55f;\n\n\t[Property, Description( \"Nombre del par\u00e1metro vector de mirada en el animgraph (el peso va en <nombre>_weight)\" )]\n\tpublic string ParametroMirada { get; set; } = \"aim_head\";\n\n\t[Property, Description( \"Calibraci\u00f3n fina: grados que se suman al yaw calculado (positivo = m\u00e1s a la izquierda). Para corregir desv\u00edos si el frame del modelo no qued\u00f3 perfecto\" )]\n\tpublic float DesvioYaw { get; set; } = 0f;\n\n\t[Property, Description( \"Calibraci\u00f3n fina: grados que se suman al pitch calculado (positivo = m\u00e1s abajo)\" )]\n\tpublic float DesvioPitch { get; set; } = 0f;\n\n\tPercepcionNextbot _percepcion;\n\tVida _vida;\n\tVector3 _direccion;   // direcci\u00f3n de mirada suavizada, en espacio de mundo\n\tfloat _peso;          // 0 = la animaci\u00f3n manda, 1 = mirada plena\n\tstring _personaje;    // para el interruptor SinCabeza de EventosAnim\n\n\tprotected override void OnStart()\n\t{\n\t\t_percepcion = Components.Get<PercepcionNextbot>();\n\t\t_vida = Components.Get<Vida>();\n\t\t_direccion = WorldRotation.Forward;\n\t\t_personaje = EventosAnim.PersonajeDe( Renderer );\n\t}\n\n\tprotected override void OnDisabled()\n\t{\n\t\t// Devolverle la cabeza a la animaci\u00f3n (muerte \u2192 ragdoll, IA apagada...)\n\t\t_peso = 0f;\n\t\t_direccion = WorldRotation.Forward;\n\n\t\tif ( Renderer.IsValid() )\n\t\t\tRenderer.SetLookDirection( ParametroMirada, _direccion, 0f );\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( !Renderer.IsValid() ) return;\n\n\t\tvar puntoMirada = BuscarPuntoMirada();\n\n\t\tvar dirDeseada = WorldRotation.Forward;\n\t\tvar pesoDeseado = 0f;\n\n\t\tif ( puntoMirada != null )\n\t\t{\n\t\t\tvar origen = WorldPosition + Vector3.Up * AlturaMirada;\n\t\t\tvar (yaw, pitch) = CalcularAngulos( puntoMirada.Value, origen );\n\n\t\t\t// Demasiado a la espalda: soltar la mirada en vez de retorcer el cuello\n\t\t\tif ( yaw != null )\n\t\t\t{\n\t\t\t\t// Reconstruir la direcci\u00f3n ya clampeada a los l\u00edmites del cuello,\n\t\t\t\t// con la calibraci\u00f3n fina encima (corrige frames imperfectos)\n\t\t\t\tdirDeseada = WorldRotation * Rotation.From( pitch.Value + DesvioPitch, yaw.Value + DesvioYaw, 0f ) * Vector3.Forward;\n\t\t\t\tpesoDeseado = 1f;\n\t\t\t}\n\t\t}\n\n\t\t// Suavizado independiente del framerate; el nodo del animgraph trae su\n\t\t// propio amortiguador (spring), esto solo gobierna entrar/salir de mirada\n\t\tvar t = 1f - MathF.Exp( -Velocidad * Time.Delta );\n\t\t_direccion = Vector3.Slerp( _direccion, dirDeseada, t ).Normal;\n\t\t_peso = _peso.LerpTo( pesoDeseado, t );\n\n\t\tRenderer.SetLookDirection( ParametroMirada, _direccion, _peso );\n\t}\n\n\t/// <summary> D\u00f3nde mirar, o null si no hay nada que mirar (o estamos muertos). </summary>\n\tVector3? BuscarPuntoMirada()\n\t{\n\t\t// Interruptor por personaje (2026-08-10): rigs GMod donde \"head\" es\n\t\t// una parte especial (tapa del libro de Buuik, tapa del pastel de\n\t\t// Cake) \u2014 el peso baja a 0 con el suavizado de siempre\n\t\tif ( EventosAnim.CabezaApagada( _personaje ) ) return null;\n\n\t\tif ( _vida.IsValid() && _vida.EstaMuerto ) return null;\n\t\tif ( !_percepcion.IsValid() || !_percepcion.Enabled ) return null;\n\n\t\tvar objetivo = _percepcion.ObjetivoActual;\n\t\tif ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return null;\n\n\t\tvar punto = _percepcion.ObjetivoVisible\n\t\t\t? objetivo.WorldPosition\n\t\t\t: _percepcion.UltimaPosicionConocida;\n\n\t\treturn punto + Vector3.Up * AlturaMirada;\n\t}\n\n\t/// <summary>\n\t/// \u00c1ngulos hacia el punto en espacio del cuerpo, ya clampeados a los\n\t/// l\u00edmites, o (null, null) si el objetivo queda demasiado a la espalda.\n\t/// </summary>\n\t(float? yaw, float? pitch) CalcularAngulos( Vector3 puntoMirada, Vector3 origen )\n\t{\n\t\tvar dirLocal = WorldRotation.Inverse * (puntoMirada - origen).Normal;\n\n\t\tvar yaw = MathF.Atan2( dirLocal.y, dirLocal.x ).RadianToDegree();\n\t\tvar pitch = -MathF.Asin( dirLocal.z.Clamp( -1f, 1f ) ).RadianToDegree();\n\n\t\tif ( MathF.Abs( yaw ) > 120f ) return (null, null);\n\n\t\treturn (yaw.Clamp( -LimiteYaw, LimiteYaw ), pitch.Clamp( -LimitePitch, LimitePitch ));\n\t}\n}\n"
        }
    ]
}