🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=ksbravo.sbhbase&take=20
Showing code results for query:
*
(73 total matches found)
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();
}
}
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 );
}
}
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;
}
}
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 );
}
}
}
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();
}
}
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();
}
}
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 );
}
}
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;
}
}
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" );
}
}
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] ),
};
}
}
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" );
}
}
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 );
}
}
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;
}
}
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/<personaje>.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;
}
}
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 );
}
}
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;
}
}
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;
}
}
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; }
}
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;
}
}
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/<personaje>.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"
}
]
}