Search the source of every open source package.
2667 results
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Twitch Poop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "twitchpoop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "garry" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "garry.twitchpoop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "6/6/2026 7:39:31 PM" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "25" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.128.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.128.0")]global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
[AssemblyInitialize]
public static void ClassInitialize( TestContext context )
{
Sandbox.Application.InitUnitTest();
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Sandbox;
namespace SFXR;
[Title( "SFXR Component" )]
[Category( "SFXR" )]
[Icon( "volume_up" )]
public sealed class SFXRComponent : Component
{
/// <summary>
/// The base Waveform
/// (Default: Square)
/// </summary>
[Property, Group( "Sound" )]
public Waveform Waveform { get; set; } = Waveform.Square;
/// <summary>
/// The sample rate of the sound
/// </summary>
[Property, Group( "Sound" )]
public SampleRate SampleRate { get; set; } = SampleRate.Hz44100;
/// <summary>
/// The bit depth of the sound
/// </summary>
// [Property, Group( "Sound" )]
public BitDepth BitDepth { get; set; } = BitDepth.Bit16;
/// <summary>
/// The length of the sound in seconds
/// </summary>
[Property, Group( "Sound" ), Range( 0f, 20f, 0.01f )]
public float Length { get; set; } = 0.5f;
/// <summary>
/// The volume of the sound
/// (Default: 0.5)
/// </summary>
[Property, Group( "Sound" ), Range( 0f, 1f, 0.01f )]
public float MasterVolume { get; set; } = 0.5f;
[Property, Group( "Frequency" ), Range( 0, 3000f, 1f )]
float StartFrequency
{
get => Frequency.Start;
set => Frequency.Start = value;
}
[Property, Group( "Frequency" ), Range( -3000f, 3000f, 1f )]
float Slide
{
get => Frequency.Slide;
set => Frequency.Slide = value;
}
[Property, Group( "Frequency" ), Range( -3000f, 3000f, 1f )]
float SlideDelta
{
get => Frequency.DeltaSlide;
set => Frequency.DeltaSlide = value;
}
/// <summary>
/// The random seed
/// </summary>
[Property, Group( "Controls" )]
public long Seed { get; set; } = 0;
[Property, Group( "Controls" )]
public SFXRControls Controls { get; set; } = new SFXRControls();
public SFXRFrequency Frequency { get; set; } = new SFXRFrequency();
Random _random = new Random();
List<SFXRNote> NotesPlaying = new();
/// <summary>
/// Plays the sound defined by the component
/// </summary>
/// <returns>The sound handle of the sound. This can be used to change position, pitch, ect</returns>
public SoundHandle PlaySound()
{
var sfx = Generate( (int)(Length * (int)SampleRate) );
var handle = sfx.Play();
// DestroyStream(sfx, Length);
return handle;
}
/// <summary>
/// Plays the sound defined by the component (Via a frequency trigger. This will play indefinitely until released)
/// </summary>
/// <param name="frequency">The frequency of the sound</param>
/// <param name="volume">The volume of the trigger </param>
public void TriggerNotePress( float frequency, float volume = 1f )
{
foreach ( var note in NotesPlaying.Where( x => x.Frequency == frequency ) )
{
note.Release();
}
var newNote = new SFXRNote( this, frequency, volume );
newNote.Trigger();
NotesPlaying.Add( newNote );
}
/// <summary>
/// Releases a note playing at the given frequency
/// </summary>
/// <param name="frequency">The frequency of the sound</param>
public void TriggerNoteRelease( float frequency )
{
foreach ( var note in NotesPlaying.Where( x => x.Frequency == frequency ) )
{
note.Release();
}
}
/// <summary>
/// Releases all notes playing
/// </summary>
public void TriggerReleaseAll()
{
foreach ( var note in NotesPlaying )
{
note.Release();
}
}
/// <summary>
/// Generates a sound stream from the component
/// </summary>
/// <param name="sampleCount">How many samples the stream should be filled with</param>
/// <returns></returns>
public SoundStream Generate( int sampleCount )
{
List<SFXREffect> effects = new();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect || !effect.Enabled ) continue;
effects.Add( effect );
}
return Generate( sampleCount, effects );
}
/// <summary>
/// Generates a sound stream from the component with the given effects
/// </summary>
/// <param name="sampleCount">The number of samples</param>
/// <param name="effects">A list of the effects to apply</param>
/// <returns></returns>
public SoundStream Generate( int sampleCount, List<SFXREffect> effects )
{
short[] samples = new short[sampleCount];
float t = 0;
for ( int i = 0; i < sampleCount; i++ )
{
t += 1f / (int)SampleRate;
short sampleValue = SFXR.GetWaveformSample( Waveform, t, Frequency.GetFrequency( t ) );
sampleValue = (short)((float)sampleValue * MasterVolume);
samples[i] = sampleValue;
}
foreach ( var effect in effects )
{
if ( !effect.Enabled ) continue;
samples = effect.Apply( samples, this );
}
var stream = new SoundStream( (int)SampleRate );
stream.WriteData( samples );
return stream;
}
/// <summary>
/// Randomizes the component's parameters
/// </summary>
public void Randomize()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
var waveform = Waveform;
ResetParameters();
Waveform = waveform;
Frequency.Start = _random.Next( 10, 3000 );
if ( _random.Next( 2 ) == 0 ) Frequency.Slide = _random.Next( -3000, 3000 );
if ( Frequency.Start > 2000 && Frequency.Slide > 200 ) Frequency.Slide = -Frequency.Slide;
else if ( Frequency.Start < 400 && Frequency.Slide < -50 ) Frequency.Slide = -Frequency.Slide;
if ( _random.Next( 2 ) == 0 ) Frequency.DeltaSlide = _random.Next( -3000, 3000 );
SanitizeParameters();
}
/// <summary>
/// Mutates the component's parameters slightly
/// </summary>
public void Mutate( float mutation = 0.05f )
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
Frequency.Start += _random.Float( -mutation, mutation ) * 1000;
if ( Frequency.Start > 2000 && Frequency.Slide > 200 ) Frequency.Slide = -Frequency.Slide;
else if ( Frequency.Start < 400 && Frequency.Slide < -50 ) Frequency.Slide = -Frequency.Slide;
Frequency.Slide += _random.Float( -mutation, mutation ) * 1000;
Frequency.DeltaSlide += _random.Float( -mutation, mutation ) * 1000;
if ( Frequency.Slide < -3000 ) Frequency.Slide = -3000;
if ( Frequency.Slide > 3000 ) Frequency.Slide = 3000;
SanitizeParameters();
}
public void RandomizePickup()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
Waveform = (Waveform)_random.Int( 0, 2 );
Frequency.Start = _random.Float( 0.4f, 0.9f ) * 3000;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = _random.Float( 0.1f, 0.3f );
envelope.Sustain = _random.Float( 0f, 0.1f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void RandomizeLaser()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var highpass = Components.GetOrCreate<SFXRHighPass>();
Waveform = (Waveform)_random.Int( 0, 2 );
if ( Waveform == Waveform.Sine && _random.Next( 2 ) == 0 ) Waveform = (Waveform)_random.Int( 0, 1 );
Frequency.Start = _random.Float( 0.6f, 0.75f ) * 3000;
Frequency.Slide = _random.Float( -0.25f, -0.15f ) * 3000;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = _random.Float( 0f, 0.4f );
envelope.Sustain = _random.Float( 0.1f, 0.3f );
envelope.Release = _random.Float( 0.25f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
if ( _random.Next( 2 ) == 0 )
{
highpass.Enabled = true;
highpass.Cutoff = _random.Float( 0f, 0.3f );
}
}
public void RandomizeExplosion()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var vibrato = Components.GetOrCreate<SFXRVibrato>();
Waveform = Waveform.Noise;
if ( _random.Next( 2 ) == 0 )
{
Frequency.Start = _random.Float( 0.025f, 0.15f ) * 3000;
Frequency.Slide = _random.Float( -0.1f, -0.01f ) * 3000;
}
else
{
Frequency.Start = _random.Float( 0.1f, 0.2f ) * 3000;
Frequency.Slide = _random.Float( -0.6f, 0.6f ) * 3000;
}
if ( _random.Next( 4 ) == 0 ) Frequency.Slide = 0;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Sustain = _random.Float( 0.1f, 0.4f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
if ( _random.Next( 2 ) == 0 )
{
vibrato.Enabled = true;
vibrato.Depth = _random.Float( 0f, 0.7f );
vibrato.Speed = _random.Float( 0f, 60f );
}
else
{
vibrato.Enabled = false;
}
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
}
public void RandomizePowerup()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var vibrato = Components.GetOrCreate<SFXRVibrato>();
if ( _random.Next( 2 ) == 0 )
{
Waveform = Waveform.Sawtooth;
}
if ( _random.Next( 2 ) == 0 )
{
Frequency.Start = _random.Float( 0.2f, 0.5f ) * 3000;
Frequency.Slide = _random.Float( 0.1f, 0.5f ) * 3000;
}
else
{
Frequency.Start = _random.Float( 0.25f, 0.5f ) * 3000;
Frequency.Slide = _random.Float( 0.05f, 0.25f ) * 3000;
if ( _random.Next( 2 ) == 0 )
{
vibrato.Enabled = true;
vibrato.Depth = _random.Float( 0, 0.7f );
vibrato.Speed = _random.Float( 0, 60f );
}
else
{
vibrato.Enabled = false;
}
}
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Sustain = _random.Float( 0f, 0.4f );
envelope.Release = _random.Float( 0.1f, 0.5f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void RandomizeHit()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
var highpass = Components.GetOrCreate<SFXRHighPass>();
Waveform = (Waveform)_random.Int( 0, 3 );
if ( Waveform == Waveform.Sine )
{
Waveform = Waveform.Noise;
}
Frequency.Start = _random.Float( 0.1f, 0.5f ) * 3000;
Frequency.Slide = _random.Float( -0.7f, -0.3f ) * 3000;
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = 0;
envelope.Sustain = _random.Float( 0.025f, 0.1f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
if ( _random.Next( 2 ) == 0 )
{
highpass.Enabled = true;
highpass.Cutoff = _random.Float( 0f, 0.3f );
}
else
{
highpass.Enabled = false;
}
}
public void RandomizeJump()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
Waveform = Waveform.Square;
Frequency.Start = _random.Float( 0.3f, 0.6f ) * 3000;
Frequency.Slide = _random.Float( 0.1f, 0.3f ) * 3000;
if ( -Frequency.Slide > Frequency.Start )
{
Frequency.Slide = -Frequency.Start;
}
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Sustain = _random.Float( 0.1f, 0.4f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void RandomizeBlip()
{
if ( Seed != 0 ) _random = new Random( (int)Seed );
ResetParameters();
foreach ( var component in GameObject.Components.GetAll() )
{
if ( component is not SFXREffect effect ) continue;
effect.Enabled = false;
}
var envelope = Components.GetOrCreate<SFXREnvelope>();
Waveform = Waveform.Square;
Frequency.Start = _random.Float( 0.2f, 0.6f ) * 3000;
envelope.Enabled = true;
envelope.Attack = 0;
envelope.Decay = _random.Float( 0.1f, 0.2f );
envelope.Sustain = _random.Float( 0.025f, 0.1f );
envelope.Release = _random.Float( 0.1f, 0.3f );
Length = envelope.Attack + envelope.Sustain + envelope.Decay + envelope.Release;
}
public void ResetParameters()
{
Waveform = Waveform.Square;
SampleRate = SampleRate.Hz44100;
BitDepth = BitDepth.Bit16;
Length = 0.5f;
MasterVolume = 0.5f;
Frequency = new SFXRFrequency();
Controls = new SFXRControls();
}
void SanitizeParameters()
{
}
protected override void OnUpdate()
{
foreach ( var note in NotesPlaying )
{
note.Update();
// if (!note.IsPlaying)
// {
// note.DestroyStreams();
// }
}
NotesPlaying.RemoveAll( x => !x.IsPlaying );
}
}
using Editor;
using Sandbox;
using Sandbox.Helpers;
using System.Collections.Generic;
using System.Linq;
namespace SFXR.Editor;
[CustomEditor( typeof( List<SFXRSequencer.Note> ) )]
public class SFXRNotesListControlWidget : ControlWidget
{
private SerializedCollection Collection;
private Layout Content;
private Button addButton;
public override bool SupportsMultiEdit => false;
SFXRSequencer Sequencer;
public SFXRNotesListControlWidget( SerializedProperty property )
: base( property )
{
SetSizeMode( SizeMode.Ignore, SizeMode.Ignore );
base.Layout = Layout.Column();
base.Layout.Spacing = 2f;
if ( property.TryGetAsObject( out var obj ) && obj is SerializedCollection collection )
{
if ( property.Parent.Targets.First() is SFXRSequencer sequencer )
{
Sequencer = sequencer;
}
Collection = collection;
Collection.OnEntryAdded = Rebuild;
Collection.OnEntryRemoved = Rebuild;
Content = Layout.Column();
base.Layout.Add( Content );
Layout layout = base.Layout.AddRow();
layout.Margin = 8;
layout.AddStretchCell();
addButton = layout.Add( new Button( "Add Note" )
{
ToolTip = "Add new note",
} );
addButton.MinimumWidth = 200;
addButton.Clicked = () => AddEntry();
layout.AddStretchCell();
Rebuild();
}
}
public void Rebuild()
{
Content.Clear( deleteWidgets: true );
Content.Margin = 0f;
Layout layout = Layout.Column();
layout.Spacing = 2f;
int num = 0;
int count = Collection.Count();
for ( int i = 0; i < count; i++ )
{
var item = Collection.ElementAt( i );
int index = num;
var itemLayout = Layout.Row();
itemLayout.Spacing = 4f;
// try to get object
if ( item.TryGetAsObject( out var obj ) )
{
var thing = new SFXRNoteSheet( obj );
itemLayout.Add( thing );
}
else
{
var thing = ControlWidget.Create( item );
thing.MinimumHeight = 100;
itemLayout.Add( thing );
}
var buttonLayout = Layout.Column();
if ( i > 0 )
{
buttonLayout.Add( new IconButton( "arrow_upward", delegate
{
MoveUp( index );
} )
{
Background = Color.Transparent,
FixedWidth = ControlWidget.ControlRowHeight,
FixedHeight = ControlWidget.ControlRowHeight,
ToolTip = "Move note up"
} );
}
else
{
buttonLayout.AddSpacingCell( 25 );
}
buttonLayout.Add( new IconButton( "delete", delegate
{
RemoveEntry( index );
} )
{
Background = Color.Red,
FixedWidth = ControlWidget.ControlRowHeight,
FixedHeight = ControlWidget.ControlRowHeight,
ToolTip = "Delete note"
} );
if ( i < count - 1 )
{
buttonLayout.Add( new IconButton( "arrow_downward", delegate
{
MoveDown( index );
} )
{
Background = Color.Transparent,
FixedWidth = ControlWidget.ControlRowHeight,
FixedHeight = ControlWidget.ControlRowHeight,
ToolTip = "Move note down"
} );
}
else
{
buttonLayout.AddSpacingCell( 25 );
}
itemLayout.Add( buttonLayout );
layout.Add( itemLayout );
num++;
}
MinimumHeight = 50 + (num * 105);
Content.Add( layout );
Content.Margin = ((num > 0) ? 3 : 0);
}
private void AddEntry()
{
Collection.Add( new SFXRSequencer.Note() );
}
private void RemoveEntry( int index )
{
Collection.RemoveAt( index );
}
private void MoveUp( int index )
{
// Move the index up in Sequencer.Notes list
if ( index > 0 )
{
var note = Sequencer.Notes[index];
Sequencer.Notes.RemoveAt( index );
Sequencer.Notes.Insert( index - 1, note );
}
Rebuild();
}
private void MoveDown( int index )
{
// Move the index down in Sequencer.Notes list
if ( index < Sequencer.Notes.Count - 1 )
{
var note = Sequencer.Notes[index];
Sequencer.Notes.RemoveAt( index );
Sequencer.Notes.Insert( index + 1, note );
}
Rebuild();
}
protected override void OnPaint()
{
}
public void AddEffectDialog( Button source )
{
var s = new SFXREffectTypeSelector( this );
s.OnSelect += ( t ) => AddEffect( t );
s.OpenAt( source.ScreenRect.BottomLeft, animateOffset: new Vector2( 0, -4 ) );
s.FixedWidth = source.Width;
}
void AddEffect( TypeDescription type )
{
if ( !type.TargetType.IsAssignableTo( typeof( SFXREffect ) ) )
{
Log.Error( $"Type {type.TargetType} is not assignable to {typeof( SFXREffect )}" );
return;
}
SFXREffect effect = type.Create<SFXREffect>();
Collection.Add( effect );
Log.Info( effect );
}
}
using System;
using System.Collections.Generic;
using Sandbox;
namespace SFXR;
[Title( "ADSR Envelope" )]
[Category( "SFXR Effects" )]
[Icon( "mail_outline" )]
public class SFXREnvelope : SFXREffect
{
/// <summary>
/// Time the sound takes to reach its peak amplitude
/// (Default: 0)
/// </summary>
[Property, Range( 0, 10 )]
public float Attack { get; set; } = 0;
/// <summary>
/// The time taken for the sound to fade to the sustain level
/// </summary>
[Property, Range( 0, 10 )]
public float Decay { get; set; } = 0;
/// <summary>
/// The level maintained until release is triggered
/// (Default: 1)
/// </summary>
[Property, Range( 0, 1 )]
public float Sustain { get; set; } = 1f;
/// <summary>
/// The time taken for the sound to fade to zero after the sustain
/// (Default: 0.3)
/// </summary>
[Property, Range( 0, 10 )]
public float SustainTime { get; set; } = 0.3f;
/// <summary>
/// The time taken for the sound to fade to zero after the release
/// (Default: 0.4)
/// </summary>
[Property, Range( 0, 10 )]
public float Release { get; set; } = 0.4f;
/// <summary>
/// Returns the amplitude of the envelope at a given time
/// </summary>
/// <param name="time">Time in seconds</param>
/// <returns>Amplitude of the envelope at the given time</returns>
public float GetAmplitude( float time )
{
return GetCurve().Evaluate( time / GetLength() );
}
public override short[] Apply( short[] samples, SFXRComponent sound )
{
// Calculate the envelope amplitude for each sample
for ( int i = 0; i < samples.Length; i++ )
{
float t = i / (float)sound.SampleRate;
float amplitude = GetAmplitude( t );
samples[i] = (short)(samples[i] * amplitude);
}
return samples;
}
public float GetLength()
{
return Attack + Decay + SustainTime + Release;
}
public Curve GetCurve()
{
Curve curve = new();
List<Vector2> points = new();
// Add the attack curve
points.Add( new Vector2( 0, 0 ) );
points.Add( new Vector2( Attack, 1 ) );
// Add the decay curve
points.Add( new Vector2( Attack + Decay, Sustain ) );
// Add the sustain curve
points.Add( new Vector2( Attack + Decay + SustainTime, Sustain ) );
// Add the release curve
points.Add( new Vector2( Attack + Decay + SustainTime + Release, 0 ) );
// Normalize the curve to 0-1 in the x
for ( int i = 0; i < points.Count; i++ )
{
points[i] = new Vector2( points[i].x / (Attack + Decay + SustainTime + Release), points[i].y );
}
// Add the points to the curve
foreach ( var point in points )
{
curve.AddPoint( point.x, point.y );
}
return curve;
}
}using Sandbox;
public sealed class SceneTrigger : Component, Component.ITriggerListener
{
[Property] public SceneFile SceneFile { get; set; }
protected override void OnUpdate()
{
}
void ITriggerListener.OnTriggerEnter(Sandbox.Collider other)
{
if (other.GameObject.Parent.Tags.Has("player") || other.GameObject.Tags.Has("boat"))
{
Game.ActiveScene.Load(SceneFile);
}
}
void ITriggerListener.OnTriggerExit(Sandbox.Collider other)
{
}
}
using System.Collections.Generic;
namespace Sandbox;
/// <summary>
/// How to use the system:
/// <code>
/// public sealed class ExampleComponent : Component
/// {
/// // Reference to the system.
/// private FixedUpdateInputSystem _fixedInput;
///
/// protected override void Start()
/// {
/// // Get the reference like this:
/// _fixedInput = Scene.GetSystem<FixedUpdateInputSystem>();
///
/// base.OnStart();
/// }
///
/// protected override void OnFixedUpdate()
/// {
/// // Query for input like usual.
/// if( _fixedInput.Pressed("jump") )
/// {
/// Log.Info("Jumped");
/// }
///
/// base.OnFixedUpdate();
/// }
/// }
/// </code>
/// </summary>
public sealed class FixedUpdateInputSystem : GameObjectSystem
{
private struct FixedUpdateInputBuffer
{
private class State
{
public bool Held;
public bool Pressed;
public bool Released;
}
private Dictionary<string, State> _actionStates;
public FixedUpdateInputBuffer()
{
_actionStates = new Dictionary<string, State>();
foreach ( var b in Input.GetActions() )
{
_actionStates[b.Name.ToLowerInvariant()] = new State();
}
}
/// <summary>
/// Call from a <see cref="Component.OnUpdate"/> method
/// to update the states of the actions.
/// </summary>
public void OnUpdate()
{
foreach ( var (name, state) in _actionStates )
{
if ( Input.Down( name ) )
_actionStates[name].Held = true;
if ( Input.Pressed( name ) )
_actionStates[name].Pressed = true;
if ( Input.Released( name ) )
_actionStates[name].Released = true;
}
}
/// <summary>
/// Call from a <see cref="Component.OnFixedUpdate"/>
/// method to get the <see cref="State.Held"/> state of this action.
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
///
public bool Held( string action )
{
return _actionStates[action.ToLowerInvariant()].Held;
}
/// <summary>
/// Call from a <see cref="Component.OnFixedUpdate"/>
/// method to get the <see cref="State.Pressed"/> state of this action.
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Pressed( string action )
{
return _actionStates[action.ToLowerInvariant()].Pressed;
}
/// <summary>
/// Call from a <see cref="Component.OnFixedUpdate"/>
/// method to get the <see cref="State.Pressed"/> state of this action.
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Released( string action )
{
return _actionStates[action.ToLowerInvariant()].Released;
}
/// <summary>
/// Call at the end of your <see cref="Component.OnFixedUpdate"/> method
/// to clear the state of the struct and reset.
/// </summary>
public void Clear()
{
foreach ( var actionName in _actionStates.Keys )
{
_actionStates[actionName].Held = false;
_actionStates[actionName].Pressed = false;
}
}
}
private FixedUpdateInputBuffer _buffer;
public FixedUpdateInputSystem( Scene scene ) : base( scene )
{
_buffer = new();
Listen( Stage.StartUpdate, int.MinValue, OnStartUpdate, "FUIB.OnStartUpdate" );
Listen( Stage.FinishFixedUpdate, int.MaxValue, OnFinishFixedUpdate, "FUIB.OnFinishFixedUpdate" );
}
private void OnStartUpdate()
{
_buffer.OnUpdate();
}
private void OnFinishFixedUpdate()
{
_buffer.Clear();
}
/// <summary>
/// Is the action currently held down?
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
///
public bool Held( string action ) => _buffer.Held( action );
/// <summary>
/// Was the action pressed?
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Pressed( string action ) => _buffer.Pressed( action );
/// <summary>
/// Was the action released?
/// </summary>
/// <param name="action">The action name (case insensitive).</param>
/// <returns></returns>
public bool Released( string action ) => _buffer.Released( action );
}
using System;
namespace Sandbox.Events;
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked before any handlers not marked as early, except if more specific
/// constraints are given (i.e., <see cref="BeforeAttribute{T}"/>, <see cref="AfterAttribute{T}"/>).
/// </summary>
[AttributeUsage( AttributeTargets.Method )]
public sealed class EarlyAttribute : Attribute
{
}
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked after any handlers not marked as late, except if more specific
/// constraints are given (i.e., <see cref="BeforeAttribute{T}"/>, <see cref="AfterAttribute{T}"/>).
/// </summary>
[AttributeUsage( AttributeTargets.Method )]
public sealed class LateAttribute : Attribute
{
}
internal interface IBeforeAttribute
{
Type Type { get; }
}
internal interface IAfterAttribute
{
Type Type { get; }
}
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked before any handlers in the specified type.
/// </summary>
[AttributeUsage( AttributeTargets.Method, AllowMultiple = true )]
public sealed class BeforeAttribute<T> : Attribute, IBeforeAttribute
{
Type IBeforeAttribute.Type => typeof(T);
}
/// <summary>
/// Only valid on <see cref="IGameEventHandler{T}.OnGameEvent"/> implementations. Forces this
/// event handler to be invoked after any handlers in the specified type.
/// </summary>
[AttributeUsage( AttributeTargets.Method, AllowMultiple = true )]
public sealed class AfterAttribute<T> : Attribute, IAfterAttribute
{
Type IAfterAttribute.Type => typeof( T );
}
using System.Collections.Generic;
using System.Linq;
namespace Sandbox.Events;
/// <summary>
/// Generate an ordering based on a set of first-most and last-most items, and
/// individual constraints between pairs of items. All first-most items will be
/// ordered before all last-most items, and any other items will be put in the
/// middle unless forced to be elsewhere by a constraint.
/// </summary>
internal class SortingHelper
{
public record struct SortConstraint( int EarlierIndex, int LaterIndex )
{
public SortConstraint Complement => new ( LaterIndex, EarlierIndex );
}
private readonly int _itemCount;
private readonly HashSet<SortConstraint> _initialConstraints = new HashSet<SortConstraint>();
private readonly HashSet<int> _first = new HashSet<int>();
private readonly HashSet<int> _last = new HashSet<int>();
public SortingHelper( int itemCount )
{
_itemCount = itemCount;
}
public void AddConstraint( int earlierIndex, int laterIndex )
{
_initialConstraints.Add( new SortConstraint( earlierIndex, laterIndex ) );
}
public void AddFirst( int earlierIndex )
{
_first.Add( earlierIndex );
}
public void AddLast( int laterIndex )
{
_last.Add( laterIndex );
}
public bool Sort( List<int> result, out SortConstraint invalidConstraint )
{
var middle = new HashSet<int>();
for ( var index = 0; index < _itemCount; ++index )
{
if ( !_first.Contains( index ) && !_last.Contains( index ) )
middle.Add( index );
}
var allConstraints = new HashSet<SortConstraint>();
var newConstraints = new Queue<SortConstraint>();
var beforeDict = new Dictionary<int, HashSet<int>>();
var afterDict = new Dictionary<int, HashSet<int>>();
bool AddWorkingConstraint( int earlierIndex, int laterIndex, out SortConstraint constraint )
{
constraint = new SortConstraint( earlierIndex, laterIndex );
if ( allConstraints.Contains( constraint.Complement ) )
return false;
if ( !allConstraints.Add( constraint ) )
return true;
newConstraints.Enqueue( constraint );
if ( !beforeDict.TryGetValue( earlierIndex, out var before ) )
beforeDict.Add( earlierIndex, before = new HashSet<int>() );
if ( !afterDict.TryGetValue( laterIndex, out var after ) )
afterDict.Add( laterIndex, after = new HashSet<int>() );
before.Add( laterIndex );
after.Add( earlierIndex );
return true;
}
// Add initial constraints
foreach ( var initialConstraint in _initialConstraints )
{
if ( !AddWorkingConstraint( initialConstraint.EarlierIndex, initialConstraint.LaterIndex, out invalidConstraint ) )
return false;
}
// Everything in _first should be before everything in _last
foreach ( var earlierIndex in _first )
{
foreach ( var laterIndex in _last )
{
if ( !AddWorkingConstraint( earlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
// Keep propagating constraints until nothing changes
while ( newConstraints.TryDequeue( out var nextConstraint ) )
{
// if a < b, and b < c, then a < c etc
if ( beforeDict.TryGetValue( nextConstraint.LaterIndex, out var before ) )
{
foreach ( var laterIndex in before )
{
if ( !AddWorkingConstraint( nextConstraint.EarlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
if ( afterDict.TryGetValue( nextConstraint.EarlierIndex, out var after ) )
{
foreach ( var earlierIndex in after )
{
if ( !AddWorkingConstraint( earlierIndex, nextConstraint.LaterIndex, out invalidConstraint ) )
{
return false;
}
}
}
}
// Now if we have any items that aren't using GroupOrder.First, and haven't
// determined that they are ordered before another item with GroupOrder.First,
// we can safely order them after all GroupOrder.First items. And vice versa.
foreach ( var middleIndex in middle )
{
var isBeforeAnyFirst = beforeDict.TryGetValue( middleIndex, out var before )
&& before.Any( x => _first.Contains( x ) );
var isAfterAnyLast = afterDict.TryGetValue( middleIndex, out var after )
&& after.Any( x => _last.Contains( x ) );
if ( !isBeforeAnyFirst )
{
foreach ( var earlierIndex in _first )
AddWorkingConstraint( earlierIndex, middleIndex, out invalidConstraint );
}
if ( !isAfterAnyLast )
{
foreach ( var laterIndex in _last )
AddWorkingConstraint( middleIndex, laterIndex, out invalidConstraint );
}
}
// Now lets add items to the final ordering if all items that should be sorted
// before them are already added to that ordering. We'll implement this by choosing
// items that have an empty list / don't appear in afterDict, and update that
// dictionary as we go.
var earliestRemaining = new Queue<int>();
// First, seed the queue with everything that's already not ordered after anything
for ( var index = 0; index < _itemCount; ++index )
{
if ( !afterDict.ContainsKey( index ) )
{
earliestRemaining.Enqueue( index );
}
}
result.Clear();
while ( earliestRemaining.TryDequeue( out var nextIndex ) )
{
result.Add( nextIndex );
foreach ( var laterIndex in beforeDict.TryGetValue( nextIndex, out var laterIndices )
? laterIndices : Enumerable.Empty<int>() )
{
var beforeLater = afterDict[laterIndex];
beforeLater.Remove( nextIndex );
if ( beforeLater.Count == 0 )
earliestRemaining.Enqueue( laterIndex );
}
}
invalidConstraint = default;
return result.Count == _itemCount;
}
}
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace Sandbox.Events;
/// <summary>
/// Interface for event payloads that can be listened for by <see cref="IGameEventHandler{T}"/>s.
/// </summary>
public interface IGameEvent { }
/// <summary>
/// Interface for components that handle game events with a payload of type <see cref="T"/>.
/// </summary>
/// <typeparam name="T">Event payload type.</typeparam>
public interface IGameEventHandler<in T>
where T : IGameEvent
{
/// <summary>
/// Called when an event with payload of type <see cref="T"/> is dispatched on a <see cref="GameObject"/>
/// that contains this component, including on a descendant.
/// </summary>
/// <param name="eventArgs">Event payload.</param>
void OnGameEvent( T eventArgs );
}
/// <summary>
/// Helper for dispatching game events in a scene.
/// </summary>
public static class GameEvent
{
private static Dictionary<Type, IReadOnlyDictionary<Type, int>> HandlerOrderingCache { get; } = new();
/// <summary>
/// Notifies all <see cref="IGameEventHandler{T}"/> components that are within <paramref name="root"/>,
/// with a payload of type <typeparamref name="T"/>.
/// </summary>
public static void Dispatch<T>( this GameObject root, T eventArgs )
where T : IGameEvent
{
var handlers = (root is Scene scene
? scene.GetAllComponents<IGameEventHandler<T>>() // I think this is more efficient?
: root.Components.GetAll<IGameEventHandler<T>>())
.ToArray();
if ( !HandlerOrderingCache.TryGetValue( typeof(T), out var ordering ) || handlers.Any( x => !ordering.ContainsKey( x.GetType() ) ) )
{
ordering = HandlerOrderingCache[typeof(T)] = GetHandlerOrdering<T>();
}
List<Exception>? exceptions = null;
foreach ( var handler in handlers.OrderBy( x => ordering[x.GetType()] ) )
{
try
{
handler.OnGameEvent( eventArgs );
}
catch ( Exception e )
{
exceptions ??= new();
exceptions.Add( e );
}
}
switch ( exceptions?.Count )
{
case 1:
Log.Error( exceptions[0] );
break;
case > 1:
Log.Error( new AggregateException( exceptions ) );
break;
}
}
private static bool IsImplementingMethodName( string methodName )
{
if ( methodName == nameof(IGameEventHandler<IGameEvent>.OnGameEvent) )
{
return true;
}
return methodName.StartsWith( "Sandbox.Events.IGameEventHandler<" ) && methodName.EndsWith( ">.OnGameEvent" );
}
private static MethodDescription? GetImplementation<T>( TypeDescription type )
{
foreach ( var method in type.Methods )
{
if ( method.IsStatic ) continue;
if ( method.Parameters.Length != 1 ) continue;
if ( method.Parameters[0].ParameterType != typeof( T ) ) continue;
if ( !IsImplementingMethodName( method.Name ) ) continue;
return method;
}
return null;
}
private static IReadOnlyDictionary<Type, int> GetHandlerOrdering<T>()
where T : IGameEvent
{
var types = TypeLibrary.GetTypes<IGameEventHandler<T>>().ToArray();
var helper = new SortingHelper( types.Length );
for ( var i = 0; i < types.Length; ++i )
{
var type = types[i];
var method = GetImplementation<T>( type );
if ( method is null )
{
Log.Warning( $"Can't find {nameof( IGameEventHandler<T> )}<{typeof( T ).Name}> implementation in {type.Name}!" );
continue;
}
foreach ( var attrib in method.Attributes )
{
switch ( attrib )
{
case EarlyAttribute:
helper.AddFirst( i );
break;
case LateAttribute:
helper.AddLast( i );
break;
case IBeforeAttribute before:
for ( var j = 0; j < types.Length; ++j )
{
if ( i == j ) continue;
var other = types[j];
if ( before.Type.IsAssignableFrom( other.TargetType ) )
{
helper.AddConstraint( i, j );
}
}
break;
case IAfterAttribute after:
for ( var j = 0; j < types.Length; ++j )
{
if ( i == j ) continue;
var other = types[j];
if ( after.Type.IsAssignableFrom( other.TargetType ) )
{
helper.AddConstraint( j, i );
}
}
break;
}
}
}
var ordering = new List<int>();
if ( !helper.Sort( ordering, out var invalid ) )
{
Log.Error( $"Invalid event ordering constraint between {types[invalid.EarlierIndex].Name} and {types[invalid.LaterIndex].Name}!" );
return ImmutableDictionary<Type, int>.Empty;
}
return Enumerable.Range( 0, ordering.Count )
.ToImmutableDictionary( i => types[ordering[i]].TargetType, i => i );
}
}
public delegate void GameEventAction<in T>( T eventArgs )
where T : IGameEvent;
/// <summary>
/// Base class for components that expose game events to Action Graph.
/// </summary>
public abstract class GameEventComponent<T> : Component, IGameEventHandler<T>
where T : IGameEvent
{
/// <summary>
/// Action invoked when the <typeparamref name="T"/> event is dispatched.
/// </summary>
[Property]
public GameEventAction<T>? OnEvent { get; set; }
/// <summary>
/// If this component is within a state machine, optional state to transition
/// to when this event is dispatched.
/// </summary>
[Property]
public StateComponent? NextState { get; set; }
void IGameEventHandler<T>.OnGameEvent( T eventArgs )
{
OnEvent?.Invoke( eventArgs );
if ( NextState is not null )
{
Components.GetInAncestorsOrSelf<StateMachineComponent>()?.Transition( NextState );
}
}
}
using System.Collections.Generic;
using System.Linq;
namespace Sandbox.Events;
/// <summary>
/// Generate an ordering based on a set of first-most and last-most items, and
/// individual constraints between pairs of items. All first-most items will be
/// ordered before all last-most items, and any other items will be put in the
/// middle unless forced to be elsewhere by a constraint.
/// </summary>
internal class SortingHelper
{
public record struct SortConstraint( int EarlierIndex, int LaterIndex )
{
public SortConstraint Complement => new ( LaterIndex, EarlierIndex );
}
private readonly int _itemCount;
private readonly HashSet<SortConstraint> _initialConstraints = new HashSet<SortConstraint>();
private readonly HashSet<int> _first = new HashSet<int>();
private readonly HashSet<int> _last = new HashSet<int>();
public SortingHelper( int itemCount )
{
_itemCount = itemCount;
}
public void AddConstraint( int earlierIndex, int laterIndex )
{
_initialConstraints.Add( new SortConstraint( earlierIndex, laterIndex ) );
}
public void AddFirst( int earlierIndex )
{
_first.Add( earlierIndex );
}
public void AddLast( int laterIndex )
{
_last.Add( laterIndex );
}
public bool Sort( List<int> result, out SortConstraint invalidConstraint )
{
var middle = new HashSet<int>();
for ( var index = 0; index < _itemCount; ++index )
{
if ( !_first.Contains( index ) && !_last.Contains( index ) )
middle.Add( index );
}
var allConstraints = new HashSet<SortConstraint>();
var newConstraints = new Queue<SortConstraint>();
var beforeDict = new Dictionary<int, HashSet<int>>();
var afterDict = new Dictionary<int, HashSet<int>>();
bool AddWorkingConstraint( int earlierIndex, int laterIndex, out SortConstraint constraint )
{
constraint = new SortConstraint( earlierIndex, laterIndex );
if ( allConstraints.Contains( constraint.Complement ) )
return false;
if ( !allConstraints.Add( constraint ) )
return true;
newConstraints.Enqueue( constraint );
if ( !beforeDict.TryGetValue( earlierIndex, out var before ) )
beforeDict.Add( earlierIndex, before = new HashSet<int>() );
if ( !afterDict.TryGetValue( laterIndex, out var after ) )
afterDict.Add( laterIndex, after = new HashSet<int>() );
before.Add( laterIndex );
after.Add( earlierIndex );
return true;
}
// Add initial constraints
foreach ( var initialConstraint in _initialConstraints )
{
if ( !AddWorkingConstraint( initialConstraint.EarlierIndex, initialConstraint.LaterIndex, out invalidConstraint ) )
return false;
}
// Everything in _first should be before everything in _last
foreach ( var earlierIndex in _first )
{
foreach ( var laterIndex in _last )
{
if ( !AddWorkingConstraint( earlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
// Keep propagating constraints until nothing changes
while ( newConstraints.TryDequeue( out var nextConstraint ) )
{
// if a < b, and b < c, then a < c etc
if ( beforeDict.TryGetValue( nextConstraint.LaterIndex, out var before ) )
{
foreach ( var laterIndex in before )
{
if ( !AddWorkingConstraint( nextConstraint.EarlierIndex, laterIndex, out invalidConstraint ) )
return false;
}
}
if ( afterDict.TryGetValue( nextConstraint.EarlierIndex, out var after ) )
{
foreach ( var earlierIndex in after )
{
if ( !AddWorkingConstraint( earlierIndex, nextConstraint.LaterIndex, out invalidConstraint ) )
{
return false;
}
}
}
}
// Now if we have any items that aren't using GroupOrder.First, and haven't
// determined that they are ordered before another item with GroupOrder.First,
// we can safely order them after all GroupOrder.First items. And vice versa.
foreach ( var middleIndex in middle )
{
var isBeforeAnyFirst = beforeDict.TryGetValue( middleIndex, out var before )
&& before.Any( x => _first.Contains( x ) );
var isAfterAnyLast = afterDict.TryGetValue( middleIndex, out var after )
&& after.Any( x => _last.Contains( x ) );
if ( !isBeforeAnyFirst )
{
foreach ( var earlierIndex in _first )
AddWorkingConstraint( earlierIndex, middleIndex, out invalidConstraint );
}
if ( !isAfterAnyLast )
{
foreach ( var laterIndex in _last )
AddWorkingConstraint( middleIndex, laterIndex, out invalidConstraint );
}
}
// Now lets add items to the final ordering if all items that should be sorted
// before them are already added to that ordering. We'll implement this by choosing
// items that have an empty list / don't appear in afterDict, and update that
// dictionary as we go.
var earliestRemaining = new Queue<int>();
// First, seed the queue with everything that's already not ordered after anything
for ( var index = 0; index < _itemCount; ++index )
{
if ( !afterDict.ContainsKey( index ) )
{
earliestRemaining.Enqueue( index );
}
}
result.Clear();
while ( earliestRemaining.TryDequeue( out var nextIndex ) )
{
result.Add( nextIndex );
foreach ( var laterIndex in beforeDict.TryGetValue( nextIndex, out var laterIndices )
? laterIndices : Enumerable.Empty<int>() )
{
var beforeLater = afterDict[laterIndex];
beforeLater.Remove( nextIndex );
if ( beforeLater.Count == 0 )
earliestRemaining.Enqueue( laterIndex );
}
}
invalidConstraint = default;
return result.Count == _itemCount;
}
}
using Sandbox;
using System.Collections.Generic;
namespace EZCameraShake
{
public class CameraShaker : Component
{
/// <summary>
/// The single instance of the CameraShaker in the current scene. Do not use if you have multiple instances.
/// </summary>
public static CameraShaker Instance;
static Dictionary<string, CameraShaker> instanceList = new Dictionary<string, CameraShaker>();
/// <summary>
/// The default position influcence of all shakes created by this shaker.
/// </summary>
[Property] public Vector3 DefaultPosInfluence = new Vector3(0.15f, 0.15f, 0.15f);
/// <summary>
/// The default rotation influcence of all shakes created by this shaker.
/// </summary>
[Property] public Vector3 DefaultRotInfluence = new Vector3(1, 1, 1);
/// <summary>
/// Offset that will be applied to the camera's default (0,0,0) rest position
/// </summary>
[Property] public Vector3 RestPositionOffset = new Vector3(0, 0, 0);
/// <summary>
/// Offset that will be applied to the camera's default (0,0,0) rest rotation
/// </summary>
[Property] public Vector3 RestRotationOffset = new Vector3(0, 0, 0);
Vector3 posAddShake, rotAddShake;
List<CameraShakeInstance> cameraShakeInstances = new List<CameraShakeInstance>();
protected override void OnAwake()
{
Instance = this;
instanceList.Add(GameObject.Name, this);
}
protected override void OnUpdate()
{
posAddShake = Vector3.Zero;
rotAddShake = Vector3.Zero;
for (int i = 0; i < cameraShakeInstances.Count; i++)
{
if (i >= cameraShakeInstances.Count)
break;
CameraShakeInstance c = cameraShakeInstances[i];
if (c.CurrentState == CameraShakeState.Inactive && c.DeleteOnInactive)
{
cameraShakeInstances.RemoveAt(i);
i--;
}
else if (c.CurrentState != CameraShakeState.Inactive)
{
posAddShake += CameraUtilities.MultiplyVectors(c.UpdateShake(), c.PositionInfluence);
rotAddShake += CameraUtilities.MultiplyVectors(c.UpdateShake(), c.RotationInfluence);
}
}
Transform.LocalPosition = (posAddShake) + RestPositionOffset;
Vector3 thing = (rotAddShake / 100) + RestRotationOffset;
Transform.LocalRotation = new Angles(thing.x, thing.y, thing.z);
}
/// <summary>
/// Gets the CameraShaker with the given name, if it exists.
/// </summary>
/// <param name="name">The name of the camera shaker instance.</param>
/// <returns></returns>
public static CameraShaker GetInstance(string name)
{
CameraShaker c;
if (instanceList.TryGetValue(name, out c))
return c;
Log.Error("CameraShake " + name + " not found!");
return null;
}
/// <summary>
/// Starts a shake using the given preset.
/// </summary>
/// <param name="shake">The preset to use.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance Shake(CameraShakeInstance shake)
{
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Shake the camera once, fading in and out over a specified durations.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <param name="fadeOutTime">How long to fade out the shake, in seconds.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance ShakeOnce(float magnitude, float roughness, float fadeInTime, float fadeOutTime)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness, fadeInTime, fadeOutTime);
shake.PositionInfluence = DefaultPosInfluence;
shake.RotationInfluence = DefaultRotInfluence;
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Shake the camera once, fading in and out over a specified durations.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <param name="fadeOutTime">How long to fade out the shake, in seconds.</param>
/// <param name="posInfluence">How much this shake influences position.</param>
/// <param name="rotInfluence">How much this shake influences rotation.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance ShakeOnce(float magnitude, float roughness, float fadeInTime, float fadeOutTime, Vector3 posInfluence, Vector3 rotInfluence)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness, fadeInTime, fadeOutTime);
shake.PositionInfluence = posInfluence;
shake.RotationInfluence = rotInfluence;
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Start shaking the camera.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance StartShake(float magnitude, float roughness, float fadeInTime)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness);
shake.PositionInfluence = DefaultPosInfluence;
shake.RotationInfluence = DefaultRotInfluence;
shake.StartFadeIn(fadeInTime);
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Start shaking the camera.
/// </summary>
/// <param name="magnitude">The intensity of the shake.</param>
/// <param name="roughness">Roughness of the shake. Lower values are smoother, higher values are more jarring.</param>
/// <param name="fadeInTime">How long to fade in the shake, in seconds.</param>
/// <param name="posInfluence">How much this shake influences position.</param>
/// <param name="rotInfluence">How much this shake influences rotation.</param>
/// <returns>A CameraShakeInstance that can be used to alter the shake's properties.</returns>
public CameraShakeInstance StartShake(float magnitude, float roughness, float fadeInTime, Vector3 posInfluence, Vector3 rotInfluence)
{
CameraShakeInstance shake = new CameraShakeInstance(magnitude, roughness);
shake.PositionInfluence = posInfluence;
shake.RotationInfluence = rotInfluence;
shake.StartFadeIn(fadeInTime);
cameraShakeInstances.Add(shake);
return shake;
}
/// <summary>
/// Gets a copy of the list of current camera shake instances.
/// </summary>
public List<CameraShakeInstance> ShakeInstances
{ get { return new List<CameraShakeInstance>(cameraShakeInstances); } }
protected override void OnDestroy()
{
instanceList.Remove(GameObject.Name);
}
}
}
public sealed class PlayerPusher : Component
{
[Property] public float Radius { get; set; } = 100;
protected override void DrawGizmos()
{
base.DrawGizmos();
Gizmo.Draw.LineSphere( Vector3.Zero, Radius );
}
public static Vector3 GetPushVector( in Vector3 position, Scene scene, GameObject ignore )
{
Vector3 vec = default;
foreach ( var pusher in scene.GetAllComponents<PlayerPusher>() )
{
if ( pusher.GameObject.IsAncestor( ignore ) )
continue;
pusher.Collect( position, ref vec );
}
return vec;
}
private void Collect( Vector3 position, ref Vector3 output )
{
var delta = (position - Transform.Position);
if ( delta.Length > Radius ) return;
delta.z = 0; // ignore z
var distanceDelta = (delta.Length / Radius);
output += delta.Normal * (1.0f - distanceDelta);
}
}
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using Sandbox;
using System.Threading.Tasks;
public sealed class WebSocketUtility : Component
{
[Property] public List<WebsocketTools> websocketToolsList { get; set; }
protected override void OnAwake()
{
foreach ( var websocketTools in websocketToolsList )
{
if ( websocketTools.url is null )
{
Log.Error( "WebsocketTools URL is null" );
return;
}
websocketTools.webSocket = new WebSocket();
ConnectToSocket( websocketTools.webSocket, websocketTools.url );
websocketTools.isConnected = true;
websocketTools.webSocket.OnMessageReceived += websocketTools.OnMessageReceivedMethod;
websocketTools.isSubscribed = true;
}
}
protected override void OnUpdate()
{
SendMessageFromList( WebsocketTools.Fetch.OnUpdate );
}
protected override void OnFixedUpdate()
{
SendMessageFromList( WebsocketTools.Fetch.OnFixedUpdate );
}
protected override void OnStart()
{
SendMessageFromList( WebsocketTools.Fetch.OnStart );
}
private async void SendMessageFromList( WebsocketTools.Fetch fetch )
{
foreach ( var websocketTools in websocketToolsList )
{
if ( websocketTools.fetch == fetch )
{
if ( websocketTools.message.UseJsonTags )
{
var jsonStrings = websocketTools.message.jsonTags.Select( tag => Json.Serialize( tag.ToString() ) );
var bigString = string.Join( "", jsonStrings );
var finalJsonString = Json.Serialize( bigString );
await websocketTools.webSocket.Send( finalJsonString );
}
else
{
var messageBytes = Encoding.UTF8.GetBytes( websocketTools.message.message );
await websocketTools.webSocket.Send( messageBytes );
}
}
}
}
[Description( "Sends a message over a websocket connection" )]
public static async Task SendAsync( WebsocketTools websocketTools )
{
if ( websocketTools.webSocket is null )
{
websocketTools.webSocket = new WebSocket();
}
if ( !websocketTools.isConnected )
{
await websocketTools.webSocket.Connect( websocketTools.url );
websocketTools.isConnected = true;
}
if ( websocketTools.message.UseJsonTags )
await websocketTools.webSocket.Send( Json.Serialize( websocketTools.message.jsonTags ) );
else
await websocketTools.webSocket.Send( websocketTools.message.message );
if ( !websocketTools.isSubscribed )
{
websocketTools.webSocket.OnMessageReceived += websocketTools.OnMessageReceivedMethod;
websocketTools.isSubscribed = true;
}
}
public static async Task SendStringAsync( string url, string message )
{
var webSocket = new WebSocket();
await webSocket.Connect( url );
await webSocket.Send( message );
}
public static void ChangeJsonTagValue( WebsocketMessage message, string tag, string value )
{
if ( message is null )
message = new WebsocketMessage();
if ( message.jsonTags is null )
message.jsonTags = new List<JsonTags>();
var jsonTag = message.jsonTags.Find( x => x.tag == tag );
if ( jsonTag is null )
{
Log.Warning( $"Tag {tag} not found in message" );
}
else
{
jsonTag.value = value;
}
}
public static void AddJsonTag( WebsocketMessage message, string tag, string value )
{
if ( message is null )
message = new WebsocketMessage();
if ( message.jsonTags is null )
message.jsonTags = new List<JsonTags>();
var jsonTag = new JsonTags
{
tag = tag,
value = value
};
message.jsonTags.Add( jsonTag );
}
private async void ConnectToSocket( WebSocket webSocket, string url )
{
await webSocket.Connect( url );
}
[ActionGraphNode( "new websocket tools" ), Pure]
public static WebsocketTools NewWebsocketTools()
{
return new WebsocketTools();
}
}
public class WebsocketTools
{
public delegate void OnMessageReceived( string message );
public OnMessageReceived onMessageReceived { get; set; }
public WebSocket webSocket { get; set; }
public string url { get; set; }
public WebsocketMessage message { get; set; } = new();
public bool isConnected { get; set; }
public bool isSubscribed { get; set; }
public string returnMessage { get; set; }
public enum Fetch
{
OnUpdate,
OnFixedUpdate,
OnStart,
}
public Fetch fetch { get; set; }
public void OnMessageReceivedMethod( string message )
{
onMessageReceived?.Invoke( message );
returnMessage = message;
}
public WebsocketTools()
{
url = "ws://localhost:8080";
fetch = Fetch.OnUpdate;
onMessageReceived = null;
message = null;
}
public WebsocketTools( string url, OnMessageReceived onMessageReceived, WebsocketMessage message, Fetch fetch = Fetch.OnUpdate )
{
this.url = url;
this.fetch = fetch;
this.onMessageReceived = onMessageReceived;
this.message = message;
}
}
[GameResource( "Message", "message", "A message to be sent over a websocket connection", Icon = "chat_bubble" )]
public class WebsocketMessage : GameResource
{
public bool UseJsonTags { get; set; }
[ShowIf( "UseJsonTags", false )] public string message { get; set; } = "";
[ShowIf( "UseJsonTags", true )] public List<JsonTags> jsonTags { get; set; } = new();
}
public class JsonTags
{
public string tag { get; set; }
public string value { get; set; }
}
using Editor;
public static class MyEditorMenu
{
[Menu( "Editor", "CrosshairBuilder/My Menu Option" )]
public static void OpenMyMenu()
{
EditorUtility.DisplayDialog( "It worked!", "This is being called from your library's editor code!" );
}
}
using Braxnet;
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
// var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
Assert.IsTrue( scene.Directory.FindByName( "LibraryTestComponent" ) != null );
}
}
}
[Autoload]
public class LibraryTestComponent : Component
{
}
public sealed class JiggleBone : TransformProxyComponent
{
JiggleBoneState state = new JiggleBoneState();
[Property]
public Vector3 StartPoint = new Vector3( 0, 0, 0 );
[Property]
public Vector3 EndPoint = new Vector3( 32, 0, 0 );
[Property, Range( 0, 2 )]
public float Speed { get; set; } = 1.0f;
[Property, Range( 0, 2 )]
public float Stiffness { get; set; } = 1.0f;
[Property, Range( 0, 2 )]
public float Damping { get; set; } = 1.0f;
[Property, Range( 0, 100 )]
public float Radius { get; set; } = 40.0f;
[Property, Range( 0, 100 )]
public float Mass { get; set; } = 1.0f;
Transform LocalJigglePosition;
protected override void OnEnabled()
{
LocalJigglePosition = Transform.Local;
base.OnEnabled();
state = new JiggleBoneState();
}
protected override void OnUpdate()
{
var oldPos = LocalJigglePosition;
using ( Transform.DisableProxy() )
{
var worldTx = Transform.World;
var startPoint = worldTx.PointToWorld( StartPoint );
var endPoint = worldTx.PointToWorld( EndPoint );
//Gizmo.Draw.LineSphere( startPoint, 1 );
//Gizmo.Draw.LineSphere( endPoint, 1 );
state.Extent = (endPoint - startPoint);
state.Stiffness = Stiffness;
state.Damping = Damping;
state.Radius = Radius;
state.Mass = Mass;
state.Update( startPoint, Time.Delta * Speed * 16.0f );
var tx = worldTx.RotateAround( startPoint, state.Rotation );
LocalJigglePosition = GameObject.Parent.Transform.World.ToLocal( tx );
}
if ( oldPos != LocalJigglePosition )
{
MarkTransformChanged();
}
}
protected override void DrawGizmos()
{
base.DrawGizmos();
if ( !Gizmo.IsSelected )
return;
using ( Transform.DisableProxy() )
{
Gizmo.Transform = Transform.World;
Gizmo.Draw.IgnoreDepth = false;
Gizmo.Draw.Color = Gizmo.Colors.Yaw.WithAlpha( 0.5f );
Gizmo.Draw.Line( StartPoint, EndPoint );
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( StartPoint, 5 ) );
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( EndPoint, 5 ) );
Gizmo.Draw.LineSphere( EndPoint, Radius * 2.0f, 4 );
}
}
public override Transform GetLocalTransform()
{
return LocalJigglePosition;
}
}
class JiggleBoneState
{
public Vector3 Extent = new Vector3( 32, 0, 0 );
public Vector3 Position { get; set; }
public Rotation Rotation { get; set; }
public float Stiffness { get; set; } = 1.0f;
public float Damping { get; set; } = 1.0f;
public float Radius { get; set; } = 10.0f;
public float Gravity { get; set; } = 1.0f;
public float Mass { get; set; } = 1.0f;
Vector3 basePosition;
Vector3 velocity;
public JiggleBoneState()
{
}
internal void Update( Vector3 position, float timeDelta )
{
basePosition = position + Extent;
// initialization
if ( Position == default )
{
Position = basePosition;
}
// Calculate spring force based on displacement from the cube
Vector3 displacement = Position - basePosition;
Vector3 springForce = -Stiffness * displacement;
// Calculate acceleration (Newton's second law)
Vector3 acceleration = springForce / Mass;
// Update velocity (integrate acceleration)
velocity += acceleration * timeDelta;
// Apply exponential damping
velocity *= (float)Math.Exp( -Damping * timeDelta );
// Update position (integrate velocity)
Position += velocity * timeDelta;
{
var diff = Position - basePosition;
var diffLen = diff.Length;
if ( diffLen > Radius )
{
Position = basePosition + diff.Normal * Radius;
//velocity = velocity.AddClamped( -diff * 2.0f, diff.Length );
}
}
// Store the rotation offset result
Rotation = Rotation.FromToRotation( basePosition - position, Position - position );
//Gizmo.Draw.IgnoreDepth = true;
//Gizmo.Draw.Line( position, Position );
//Gizmo.Draw.Line( basePosition, Position );
}
}
using Sandbox;
/// <summary>
/// This is a component - in your library!
/// </summary>
[Title( "LibraryImporter - My Component" )]
public class MyLibraryComponent : Component
{
}
using Sandbox;
public sealed class CameraMovement : Component
{
[Property] public CharacterController1 Player { get; set; }
[Property] public GameObject Body { get; set; }
[Property] public GameObject Head { get; set; }
[Property] public float Distance { get; set; } = 0f;
[Property] public float Sensitivity { get; set; } = 0.1f;
public bool IsFirstPerson => Distance == 0f;
private CameraComponent Camera;
private ModelRenderer BodyRenderer;
private Vector3 CurrentOffset = Vector3.Zero;
protected override void OnAwake()
{
base.OnAwake();
Camera = Components.Get<CameraComponent>();
BodyRenderer = Body.Components.Get<ModelRenderer>();
}
protected override void OnUpdate()
{
var eyeAngles = Head.Transform.Rotation.Angles();
eyeAngles.pitch += Input.MouseDelta.y * Sensitivity;
eyeAngles.yaw -= Input.MouseDelta.x * Sensitivity;
eyeAngles.roll = 0f;
eyeAngles.pitch = eyeAngles.pitch.Clamp( -89.9f, 89.9f );
Head.Transform.Rotation = eyeAngles.ToRotation();
var targetOffset = Vector3.Zero;
if ( Player.IsCrouching ) targetOffset += Vector3.Down * 35f;
CurrentOffset = Vector3.Lerp( CurrentOffset, targetOffset, Time.Delta * 10f );
if ( Camera is not null )
{
var camPos = Head.Transform.Position + CurrentOffset;
if ( !IsFirstPerson )
{
var camForward = eyeAngles.ToRotation().Forward;
var camTrace = Scene.Trace.Ray( camPos, camPos - (camForward * Distance) )
.WithoutTags( "player", "trigger" )
.Run();
if ( camTrace.Hit )
{
camPos = camTrace.HitPosition + camTrace.Normal;
}
else
{
camPos = camTrace.EndPosition;
}
BodyRenderer.RenderType = ModelRenderer.ShadowRenderType.On;
}
else
{
BodyRenderer.RenderType = ModelRenderer.ShadowRenderType.ShadowsOnly;
}
Log.Info( CurrentOffset );
Camera.Transform.Position = camPos;
Camera.Transform.Rotation = eyeAngles.ToRotation();
}
}
}
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
using Editor;
global using System;
global using System.Linq;
global using System.Collections.Generic;
global using Editor;
global using Sandbox;
global using PathTool;
global using Application = Editor.Application;
using System.Collections.Generic;
using Sandbox.Diagnostics;
namespace NPBehave
{
public class Parallel : Composite
{
public enum Policy
{
One,
All,
}
// public enum Wait
// {
// NEVER,
// ON_FAILURE,
// ON_SUCCESS,
// BOTH
// }
// private Wait waitForPendingChildrenRule;
private Policy _failurePolicy;
private Policy _successPolicy;
private int _childrenCount = 0;
private int _runningCount = 0;
private int _succeededCount = 0;
private int _failedCount = 0;
private Dictionary<Node, bool> _childrenResults;
private bool _successState;
private bool _childrenAborted;
public Parallel(Policy successPolicy, Policy failurePolicy, /*Wait waitForPendingChildrenRule,*/ params Node[] children) : base("Parallel", children)
{
_successPolicy = successPolicy;
_failurePolicy = failurePolicy;
// this.waitForPendingChildrenRule = waitForPendingChildrenRule;
_childrenCount = children.Length;
_childrenResults = new Dictionary<Node, bool>();
}
protected override void DoStart()
{
foreach (Node child in Children)
{
Assert.AreEqual(child.CurrentState, State.Inactive);
}
_childrenAborted = false;
_runningCount = 0;
_succeededCount = 0;
_failedCount = 0;
foreach (Node child in Children)
{
_runningCount++;
child.Start();
}
}
protected override void DoStop()
{
Assert.True(_runningCount + _succeededCount + _failedCount == _childrenCount);
foreach (Node child in Children)
{
if (child.IsActive)
{
child.Stop();
}
}
}
protected override void DoChildStopped(Node child, bool result)
{
_runningCount--;
if (result)
{
_succeededCount++;
}
else
{
_failedCount++;
}
_childrenResults[child] = result;
bool allChildrenStarted = _runningCount + _succeededCount + _failedCount == _childrenCount;
if (allChildrenStarted)
{
if (_runningCount == 0)
{
if (!_childrenAborted) // if children got aborted because rule was evaluated previously, we don't want to override the successState
{
if (_failurePolicy == Policy.One && _failedCount > 0)
{
_successState = false;
}
else if (_successPolicy == Policy.One && _succeededCount > 0)
{
_successState = true;
}
else if (_successPolicy == Policy.All && _succeededCount == _childrenCount)
{
_successState = true;
}
else
{
_successState = false;
}
}
Stopped(_successState);
}
else if (!_childrenAborted)
{
Assert.False(_succeededCount == _childrenCount);
Assert.False(_failedCount == _childrenCount);
if (_failurePolicy == Policy.One && _failedCount > 0/* && waitForPendingChildrenRule != Wait.ON_FAILURE && waitForPendingChildrenRule != Wait.BOTH*/)
{
_successState = false;
_childrenAborted = true;
}
else if (_successPolicy == Policy.One && _succeededCount > 0/* && waitForPendingChildrenRule != Wait.ON_SUCCESS && waitForPendingChildrenRule != Wait.BOTH*/)
{
_successState = true;
_childrenAborted = true;
}
if (_childrenAborted)
{
foreach (Node currentChild in Children)
{
if (currentChild.IsActive)
{
currentChild.Stop();
}
}
}
}
}
}
public override void StopLowerPriorityChildrenForChild(Node abortForChild, bool immediateRestart)
{
if (immediateRestart)
{
Assert.False(abortForChild.IsActive);
if (_childrenResults[abortForChild])
{
_succeededCount--;
}
else
{
_failedCount--;
}
_runningCount++;
abortForChild.Start();
}
else
{
throw new Exception("On Parallel Nodes all children have the same priority, thus the method does nothing if you pass false to 'immediateRestart'!");
}
}
}
}
using System.Collections;
using Sandbox.Diagnostics;
namespace NPBehave
{
public class RandomSequence : Composite
{
static System.Random _rng = new System.Random();
#if DEBUG
static public void DebugSetSeed( int seed )
{
_rng = new System.Random( seed );
}
#endif
private int _currentIndex = -1;
private int[] _randomizedOrder;
public RandomSequence(params Node[] children) : base("Random Sequence", children)
{
_randomizedOrder = new int[children.Length];
for (int i = 0; i < Children.Length; i++)
{
_randomizedOrder[i] = i;
}
}
protected override void DoStart()
{
foreach (Node child in Children)
{
Assert.AreEqual(child.CurrentState, State.Inactive);
}
_currentIndex = -1;
// Shuffling
int n = _randomizedOrder.Length;
while (n > 1)
{
int k = _rng.Next(n--);
(_randomizedOrder[n], _randomizedOrder[k]) = (_randomizedOrder[k], _randomizedOrder[n]);
}
ProcessChildren();
}
protected override void DoStop()
{
Children[_randomizedOrder[_currentIndex]].Stop();
}
protected override void DoChildStopped(Node child, bool result)
{
if (result)
{
ProcessChildren();
}
else
{
Stopped(false);
}
}
private void ProcessChildren()
{
if (++_currentIndex < Children.Length)
{
if (IsStopRequested)
{
Stopped(false);
}
else
{
Children[_randomizedOrder[_currentIndex]].Start();
}
}
else
{
Stopped(true);
}
}
public override void StopLowerPriorityChildrenForChild(Node abortForChild, bool immediateRestart)
{
int indexForChild = 0;
bool found = false;
foreach (Node currentChild in Children)
{
if (currentChild == abortForChild)
{
found = true;
}
else if (!found)
{
indexForChild++;
}
else if (found && currentChild.IsActive)
{
if (immediateRestart)
{
_currentIndex = indexForChild - 1;
}
else
{
_currentIndex = Children.Length;
}
currentChild.Stop();
break;
}
}
}
public override string ToString()
{
return $"{base.ToString()}[{_currentIndex}]";
}
}
}
namespace NPBehave
{
public class Succeeder : Decorator
{
public Succeeder(Node decoratee) : base("Succeeder", decoratee)
{
}
protected override void DoStart()
{
Decoratee.Start();
}
protected override void DoStop()
{
Decoratee.Stop();
}
protected override void DoChildStopped(Node child, bool result)
{
Stopped(true);
}
}
}using System;
namespace NPBehave
{
public class Exception : System.Exception
{
public Exception(string message) : base(message)
{
}
}
}namespace NPBehave
{
public class Repeater : Decorator
{
private int _loopCount = -1;
private int _currentLoop;
/// <param name="loopCount">number of times to execute the decoratee. Set to -1 to repeat forever, be careful with endless loops!</param>
/// <param name="decoratee">Decorated Node</param>
public Repeater(int loopCount, Node decoratee) : base("Repeater", decoratee)
{
_loopCount = loopCount;
}
/// <param name="decoratee">Decorated Node, repeated forever</param>
public Repeater(Node decoratee) : base("Repeater", decoratee)
{
}
protected override void DoStart()
{
if (_loopCount != 0)
{
_currentLoop = 0;
Decoratee.Start();
}
else
{
Stopped(true);
}
}
protected override void DoStop()
{
Clock.RemoveTimer(RestartDecoratee);
if (Decoratee.IsActive)
{
Decoratee.Stop();
}
else
{
Stopped(false);
}
}
protected override void DoChildStopped(Node child, bool result)
{
if (result)
{
if (IsStopRequested || (_loopCount > 0 && ++_currentLoop >= _loopCount))
{
Stopped(true);
}
else
{
Clock.AddTimer(0, 0, RestartDecoratee);
}
}
else
{
Stopped(false);
}
}
protected void RestartDecoratee()
{
Decoratee.Start();
}
}
}global using Sandbox;
global using System.Collections.Generic;
global using System.Linq;
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
[AssemblyInitialize]
public static void ClassInitialize( TestContext context )
{
Sandbox.Application.InitUnitTest();
}
}
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
global using Sandbox;
global using System.Collections.Generic;
global using System.Linq;
using System.Threading.Tasks;
using System.Threading;
using System;
namespace Duccsoft;
/// <summary>
/// Provides a handy asynchronous wrapper for loading a VideoPlayer and waiting
/// until its video and audio are both loaded.
/// </summary>
public class AsyncVideoLoader
{
public AsyncVideoLoader()
{
_videoPlayer = new VideoPlayer();
}
public AsyncVideoLoader( VideoPlayer player )
{
_videoPlayer = player ?? new VideoPlayer();
}
public bool IsLoading { get; private set; }
private VideoPlayer _videoPlayer;
private Action _onLoaded;
private Action _onAudioReady;
public async Task<VideoPlayer> LoadFromUrl( string url, CancellationToken cancelToken = default )
{
void Play( VideoPlayer player ) => player.Play( url );
await Load( Play, cancelToken );
return _videoPlayer;
}
public async Task<VideoPlayer> LoadFromFile( BaseFileSystem fileSystem, string path, CancellationToken cancelToken )
{
void Play( VideoPlayer player ) => player.Play( fileSystem, path );
await Load( Play, cancelToken );
return _videoPlayer;
}
private async Task Load( Action<VideoPlayer> playAction, CancellationToken cancelToken = default )
{
// Attempting to play a video from a thread would throw an exception.
await GameTask.MainThread( cancelToken );
if ( IsLoading )
{
throw new InvalidOperationException( "Another video was already being loaded. Check IsLoading or create a new instance of AsyncVideoLoader." );
}
IsLoading = true;
bool videoLoaded = false;
bool audioLoaded = false;
// Assign private members instead of named methods to the invocation lists of the
// VideoPlayer delegates to break reference equality between runs.
_onLoaded = () => videoLoaded = true;
_onAudioReady = () => audioLoaded = true;
_videoPlayer.OnLoaded = _onLoaded;
_videoPlayer.OnAudioReady = _onAudioReady;
playAction?.Invoke( _videoPlayer );
// Non-blocking spin until video and audio are loaded.
while ( !videoLoaded || !audioLoaded )
{
// If OnLoaded or OnAudioReady are changed externally before we're finished
// loading, the video will likely never load. Abort to avoid spinning forever.
var callbacksChanged = _onLoaded != _videoPlayer.OnLoaded || _onAudioReady != _videoPlayer.OnAudioReady;
if ( callbacksChanged || cancelToken.IsCancellationRequested )
{
IsLoading = false;
return;
}
await GameTask.Yield();
}
IsLoading = false;
}
}
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.IO;
using Editor;
using Sandbox;
using System.Text;
class SpectogramWidget : Widget
{
private short[] samples;
private int sampleRate;
private List<int> splitPoints = new List<int>();
private int? dragPoint = null;
private Label loadingLabel;
private Label dropLabel;
private bool isLoading = true;
public SoundFile CurrentSound { get; private set; }
public SpectogramWidget(SoundFile soundFile) : base(null)
{
MinimumSize = 100;
MouseTracking = true;
AcceptDrops = true;
loadingLabel = new Label(this);
loadingLabel.Text = "Loading audio data...";
loadingLabel.Visible = false;
dropLabel = new Label(this);
dropLabel.Text = "Drop a sound file here";
dropLabel.SetStyles("font-size: 18px; color: #aaa; text-align: center;");
if (soundFile != null)
{
LoadSound(soundFile);
}
}
public async void LoadSound(SoundFile soundFile)
{
CurrentSound = soundFile;
isLoading = true;
samples = null;
splitPoints.Clear();
loadingLabel.Visible = true;
dropLabel.Visible = false;
await LoadAudioDataAsync(soundFile);
}
private async Task LoadAudioDataAsync(SoundFile soundFile)
{
try
{
await soundFile.LoadAsync();
samples = await soundFile.GetSamplesAsync();
if (samples == null)
{
loadingLabel.Text = "Failed to load audio data";
return;
}
sampleRate = soundFile.Rate;
splitPoints.Add(0);
splitPoints.Add(samples.Length - 1);
loadingLabel.Visible = false;
isLoading = false;
Update();
}
catch (Exception ex)
{
loadingLabel.Text = $"Error loading audio: {ex.Message}";
}
}
protected override void DoLayout()
{
base.DoLayout();
if (loadingLabel != null)
{
loadingLabel.Position = new Vector2(10, Height / 2 - 10);
loadingLabel.Size = new Vector2(Width - 20, 20);
}
if (dropLabel != null)
{
dropLabel.Position = new Vector2(10, Height / 2 - 10);
dropLabel.Size = new Vector2(Width - 20, 20);
}
}
public override void OnDragDrop(DragEvent e)
{
base.OnDragDrop(e);
if (!e.Data.HasFileOrFolder) return;
var asset = AssetSystem.FindByPath(e.Data.FileOrFolder);
if (asset?.AssetType != AssetType.SoundFile) return;
var soundFile = SoundFile.Load(asset.Path);
if (soundFile != null)
{
LoadSound(soundFile);
}
}
public override void OnDragHover(DragEvent e)
{
base.OnDragHover(e);
if (!e.Data.HasFileOrFolder) return;
var asset = AssetSystem.FindByPath(e.Data.FileOrFolder);
if (asset?.AssetType != AssetType.SoundFile) return;
e.Action = DropAction.Link;
}
protected override void OnMouseClick(MouseEvent e)
{
if (isLoading) return;
base.OnMouseClick(e);
if (e.Button == MouseButtons.Left)
{
var samplePos = (int)(e.LocalPosition.x / Width * samples.Length);
var nearPoint = splitPoints.FirstOrDefault(p => Math.Abs(p - samplePos) < (samples.Length / Width * 5));
if (nearPoint != default)
{
dragPoint = splitPoints.IndexOf(nearPoint);
}
else
{
splitPoints.Add(samplePos);
splitPoints.Sort();
Update();
}
}
}
protected override void OnMouseMove(MouseEvent e)
{
if (isLoading) return;
base.OnMouseMove(e);
if (dragPoint.HasValue)
{
var samplePos = (int)(e.LocalPosition.x / Width * samples.Length);
splitPoints[dragPoint.Value] = samplePos;
splitPoints.Sort();
Update();
}
}
protected override void OnMouseReleased(MouseEvent e)
{
if (isLoading) return;
base.OnMouseReleased(e);
dragPoint = null;
}
protected override void OnPaint()
{
base.OnPaint();
if (isLoading || samples == null)
{
return;
}
Paint.ClearPen();
Paint.SetBrush(Theme.Grey.WithAlpha(0.1f));
Paint.DrawRect(LocalRect);
Paint.SetPen(Theme.Blue);
var samplesPerPixel = samples.Length / Width;
for (int x = 0; x < Width; x++)
{
var startSample = (int)(x * samplesPerPixel);
var endSample = Math.Min(startSample + samplesPerPixel, samples.Length);
var max = short.MinValue;
var min = short.MaxValue;
for (int i = startSample; i < endSample; i++)
{
max = Math.Max(max, samples[i]);
min = Math.Min(min, samples[i]);
}
var y1 = Height / 2 + (min / (float)short.MaxValue * Height / 2);
var y2 = Height / 2 + (max / (float)short.MaxValue * Height / 2);
Paint.DrawLine(new Vector2(x, y1), new Vector2(x, y2));
}
Paint.SetPen(Theme.Red);
foreach (var point in splitPoints)
{
var x = point / (float)samples.Length * Width;
Paint.DrawLine(new Vector2(x, 0), new Vector2(x, Height));
}
}
public List<int> GetSplitPoints()
{
return new List<int>(splitPoints);
}
public void SplitCurrentSound(Action<SoundFile> onSoundCreated)
{
if (CurrentSound == null || samples == null) return;
var splitPoints = GetSplitPoints();
if (splitPoints.Count < 2) return;
try
{
var baseFileName = Path.GetFileNameWithoutExtension(CurrentSound.ResourcePath);
var outputDir = Path.Combine(
Project.Current.GetAssetsPath(),
"generated",
$"{baseFileName}_splits"
);
Directory.CreateDirectory(outputDir);
for (int i = 0; i < splitPoints.Count - 1; i++)
{
var start = splitPoints[i];
var end = splitPoints[i + 1];
var length = end - start;
var segmentSamples = new short[length];
Array.Copy(samples, start, segmentSamples, 0, length);
var wavPath = Path.Combine(outputDir, $"{baseFileName}_part_{i + 1}.wav");
using (var writer = new BinaryWriter(File.Create(wavPath)))
{
writer.Write(Encoding.ASCII.GetBytes("RIFF"));
writer.Write(36 + (segmentSamples.Length * 2));
writer.Write(Encoding.ASCII.GetBytes("WAVE"));
writer.Write(Encoding.ASCII.GetBytes("fmt "));
writer.Write(16);
writer.Write((short)1);
writer.Write((short)CurrentSound.Channels);
writer.Write(CurrentSound.Rate);
writer.Write(CurrentSound.Rate * CurrentSound.Channels * 2);
writer.Write((short)(CurrentSound.Channels * 2));
writer.Write((short)16);
writer.Write(Encoding.ASCII.GetBytes("data"));
writer.Write(segmentSamples.Length * 2);
foreach (var sample in segmentSamples)
{
writer.Write(sample);
}
}
var asset = AssetSystem.RegisterFile(wavPath);
if (asset != null)
{
var soundFile = SoundFile.Load(asset.RelativePath);
if (soundFile != null)
{
onSoundCreated?.Invoke(soundFile);
}
}
}
}
catch (Exception ex)
{
Log.Error($"Error splitting sound: {ex.Message}");
}
}
}using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
// ═══════════════════════════════════════════════════════════════════════════
// NPC Brains — Feature Wave #3 (Phase 1 + simulate_npc_perception)
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs, so it can use the
// shared helpers there directly: ClaudeBridge.TryResolveProjectPath /
// SanitizeIdentifier / ParseVector3, SceneToolHelpers.*, and the IBridgeHandler
// interface. These handlers run in the UNSANDBOXED editor (System.Math/MathF/IO
// are all fine here).
//
// The C# *strings these handlers generate* run in the SANDBOX (the game). That
// generated code is deliberately restricted to APIs already proven to compile in
// the sandbox by the existing create_npc_controller / create_networked_player
// generators: Component, [Property], [Sync], GetOrAddComponent<NavMeshAgent>(),
// NavMeshAgent.MoveTo(Vector3), IsProxy, TimeSince, Vector3.Dot/.Normal/
// .DistanceBetween, Scene.GetAllComponents<T>(), scene.Trace.Ray(a,b).Run(),
// MathX.Clamp. MathX preferred in generated code; System.Math/MathF also compile on the current SDK (verified 2026-06-09). Array.Clone() still blocked.
//
// Tools in this file:
// create_npc_brain (code-gen; scene-mutating)
// place_patrol_route (scene-mutating)
// assign_patrol_route (scene-mutating)
// create_npc_spawner (code-gen; scene-mutating)
// simulate_npc_perception (READ-ONLY; not scene-mutating)
//
// Register(...) lines + _sceneMutatingCommands additions are wired by the main
// agent in MyEditorMenu.cs (see this wave's summary) to avoid a merge conflict.
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Shared helpers for the NPC-brain generators. Kept internal to this file so it
/// does not collide with anything in MyEditorMenu.cs.
/// </summary>
internal static class NpcBrainHelpers
{
/// <summary>
/// Read an optional float param, falling back to <paramref name="fallback"/>.
/// Tolerates the value arriving as a JSON number OR a numeric string.
/// </summary>
public static float Float( JsonElement p, string key, float fallback )
{
if ( !p.TryGetProperty( key, out var e ) ) return fallback;
if ( e.ValueKind == JsonValueKind.Number && e.TryGetSingle( out var f ) ) return f;
if ( e.ValueKind == JsonValueKind.String && float.TryParse( e.GetString(), out var fs ) ) return fs;
return fallback;
}
public static int Int( JsonElement p, string key, int fallback )
{
if ( !p.TryGetProperty( key, out var e ) ) return fallback;
if ( e.ValueKind == JsonValueKind.Number && e.TryGetInt32( out var i ) ) return i;
if ( e.ValueKind == JsonValueKind.String && int.TryParse( e.GetString(), out var iss ) ) return iss;
return fallback;
}
public static bool Bool( JsonElement p, string key, bool fallback )
{
if ( !p.TryGetProperty( key, out var e ) ) return fallback;
if ( e.ValueKind == JsonValueKind.True ) return true;
if ( e.ValueKind == JsonValueKind.False ) return false;
if ( e.ValueKind == JsonValueKind.String && bool.TryParse( e.GetString(), out var b ) ) return b;
return fallback;
}
public static string Str( JsonElement p, string key, string fallback )
{
if ( p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.String )
{
var s = e.GetString();
if ( !string.IsNullOrWhiteSpace( s ) ) return s;
}
return fallback;
}
/// <summary>
/// Format a float as an invariant-culture C# literal with an 'f' suffix, e.g.
/// 130 -> "130f", 0.25 -> "0.25f". Invariant culture matters so a comma-decimal
/// locale on the editor machine cannot emit "0,25f" and break compilation.
/// </summary>
public static string F( float v )
{
var s = v.ToString( "0.0###", System.Globalization.CultureInfo.InvariantCulture );
return s + "f";
}
/// <summary>
/// Escape a user string for safe embedding inside a C# double-quoted verbatim
/// string ( @"" ), where the only escape needed is doubling the quote char.
/// TargetTag is also identifier-ish but tags can legitimately contain symbols,
/// so we keep it a string literal rather than sanitizing it to an identifier.
/// </summary>
public static string EscVerbatim( string raw ) => ( raw ?? "" ).Replace( "\"", "\"\"" );
/// <summary>
/// cos( fovDegrees / 2 ) computed in the EDITOR (MathF is legal here). Baked as
/// the default of the generated CosFovThreshold property so the sandbox brain
/// never needs trig. Clamped to a sane FOV range first.
/// </summary>
public static float CosHalfFov( float fovDegrees )
{
var fov = Math.Clamp( fovDegrees, 1f, 360f );
var halfRad = ( fov * 0.5f ) * ( MathF.PI / 180f );
return MathF.Cos( halfRad );
}
/// <summary>
/// Resolve the component on <paramref name="go"/> that exposes a property named
/// <paramref name="property"/>, and SET that property to <paramref name="value"/>.
/// Preferred match is a component literally named "NpcBrain"; otherwise the first
/// component whose TypeLibrary description has that property. Returns the matched
/// component (so the caller can report its name), or null if none matched.
///
/// We deliberately do the find+set inside one method so this file never has to
/// name the reflection types (TypeDescription / PropertyDescription) — the rest
/// of the addon always uses `var` for them, which means their namespace is not
/// guaranteed to be importable here. Keeping it all behind `var` mirrors the
/// proven SetPrefabRefHandler pattern exactly.
/// </summary>
public static Component SetComponentProperty( GameObject go, string property, object value )
{
Component fallbackComp = null;
// Pass 1: prefer an NpcBrain. Pass 2: any component exposing the property.
foreach ( var c in go.Components.GetAll() )
{
var td = Game.TypeLibrary.GetType( c.GetType().Name );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
if ( pd == null ) continue;
if ( c.GetType().Name.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
{
pd.SetValue( c, value );
return c;
}
fallbackComp = fallbackComp ?? c;
}
if ( fallbackComp != null )
{
var td = Game.TypeLibrary.GetType( fallbackComp.GetType().Name );
var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
pd?.SetValue( fallbackComp, value );
}
return fallbackComp;
}
/// <summary>
/// Find the "perception brain" component on <paramref name="go"/> — the component
/// simulate_npc_perception should read SightRange/FovDegrees/EyeHeight/TargetTag from.
///
/// Why not just match the type name "NpcBrain": a custom-named brain (e.g. BigfootBrain,
/// generated via create_npc_brain with name="BigfootBrain") exposes the same perception
/// [Property] surface but a different type name, so a literal name match silently falls
/// back to spec defaults. We match by CAPABILITY instead:
/// 1. a component literally named "NpcBrain" (the default), else
/// 2. a component whose TypeLibrary description exposes BOTH SightRange and FovDegrees
/// (the perception contract), else
/// 3. a component whose type name ends with "Brain".
/// Returns null if none match (caller then uses defaults / explicit overrides).
/// </summary>
public static Component FindPerceptionBrain( GameObject go )
{
if ( go == null ) return null;
Component byProps = null;
Component byName = null;
foreach ( var c in go.Components.GetAll() )
{
var typeName = c.GetType().Name;
// 1. Exact "NpcBrain" wins immediately (the generated default).
if ( typeName.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
return c;
// 2. Capability match: exposes the perception property contract.
if ( byProps == null )
{
var td = Game.TypeLibrary.GetType( typeName );
if ( td != null
&& td.Properties.Any( pp => pp.Name == "SightRange" )
&& td.Properties.Any( pp => pp.Name == "FovDegrees" ) )
{
byProps = c;
}
}
// 3. Name heuristic: "...Brain".
if ( byName == null && typeName.EndsWith( "Brain", StringComparison.OrdinalIgnoreCase ) )
byName = c;
}
return byProps ?? byName;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. create_npc_brain (code-gen; scene-mutating)
// Generates an NpcBrain Component: a finite-state machine (Idle/Patrol/
// Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception
// (FOV cone + range + LOS trace + hearing) with last-known-position memory.
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNpcBrainHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var name = NpcBrainHelpers.Str( p, "name", "NpcBrain" );
var directory = NpcBrainHelpers.Str( p, "directory", "Code" );
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )
return Task.FromResult<object>( new { error = pathErr } );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );
// ── Preset → defaults. The generated file is identical shape; the preset
// only changes [Property] defaults (StartState, CanFlee).
var behavior = NpcBrainHelpers.Str( p, "behavior", "hunter" ).ToLowerInvariant();
string startState;
bool presetCanFlee;
switch ( behavior )
{
case "patrol": startState = "Patrol"; presetCanFlee = false; break;
case "guard": startState = "Ambush"; presetCanFlee = false; break;
case "swarm": startState = "Wander"; presetCanFlee = false; break;
case "skittish": startState = "Patrol"; presetCanFlee = true; break;
case "hunter":
default: behavior = "hunter"; startState = "Patrol"; presetCanFlee = false; break;
}
// ── Tunables (params override preset/spec defaults). ──
var moveSpeed = NpcBrainHelpers.Float( p, "moveSpeed", 130f );
var chaseSpeed = NpcBrainHelpers.Float( p, "chaseSpeed", 200f );
var sightRange = NpcBrainHelpers.Float( p, "sightRange", 1500f );
var fovDegrees = NpcBrainHelpers.Float( p, "fovDegrees", 110f );
var eyeHeight = NpcBrainHelpers.Float( p, "eyeHeight", 64f );
var hearingRadius = NpcBrainHelpers.Float( p, "hearingRadius", 600f );
var giveUpTime = NpcBrainHelpers.Float( p, "giveUpTime", 6f );
var searchRadius = NpcBrainHelpers.Float( p, "searchRadius", 400f );
var waypointStop = NpcBrainHelpers.Float( p, "waypointStopDistance", 80f );
var canFlee = NpcBrainHelpers.Bool( p, "canFlee", presetCanFlee );
var fleeHealth = NpcBrainHelpers.Float( p, "fleeHealthFrac", 0.25f );
var networked = NpcBrainHelpers.Bool( p, "networked", true );
var targetTag = NpcBrainHelpers.Str( p, "targetTag", "player" );
// Citizen locomotion animation: when on (default), the generated brain caches a
// SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle
// from the NavMeshAgent each frame (so the NPC and every spawner clone animate
// instead of sliding in bind pose). Proven approach ported from BigfootBrain.cs.
var animate = NpcBrainHelpers.Bool( p, "animate", true );
var cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );
var code = BuildSource(
className, startState, networked, animate,
NpcBrainHelpers.EscVerbatim( targetTag ),
moveSpeed, chaseSpeed, sightRange, fovDegrees, cosFov, eyeHeight,
hearingRadius, giveUpTime, searchRadius, waypointStop, canFlee, fleeHealth );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
File.WriteAllText( fullPath, code );
var states = new[] { "Idle", "Patrol", "Wander", "Chase", "Search", "Flee", "Ambush" };
var props = new[]
{
"StartState","MoveSpeed","ChaseSpeed","SightRange","FovDegrees","CosFovThreshold",
"EyeHeight","HearingRadius","TargetTag","GiveUpTime","SearchRadius","WaypointStopDistance",
"PingPong","CanFlee","FleeHealthFrac","CurrentHealthFrac","Waypoints","CurrentState"
};
return Task.FromResult<object>( new
{
created = true,
path = $"{directory}/{fileName}",
className,
behavior,
networked,
animate,
statesIncluded = states,
propertyNames = props,
note = "NavMeshAgent is added automatically via GetOrAddComponent in OnStart. " +
"Requires bake_navmesh + a navmesh-walkable scene for movement. " +
"Assign a patrol route with place_patrol_route + assign_patrol_route. " +
"Verify perception in EDIT mode with simulate_npc_perception; verify chase/search by entering play mode " +
"(get_runtime_property CurrentState + timed screenshot_from). " +
( animate
? "Locomotion animation ON: caches a SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle from the NavMeshAgent each frame — attach this brain to a GameObject with a Citizen (or any SkinnedModel) renderer (on it or a child) and it animates while moving instead of sliding. Spawner clones inherit it (each runs its own OnStart). Pass animate:false to disable. "
: "Locomotion animation OFF (animate:false): the NPC slides in bind pose; drive a CitizenAnimationHelper yourself if you want walk/run anims. " ) +
( networked
? "Networked: host-authoritative (if(IsProxy)return) + [Sync] CurrentState — needs a host session; a no-session solo playtest makes everything a proxy so the brain won't think (use networked:false to iterate solo)."
: "Solo/edit build: no IsProxy guard, so it ticks in a single-machine playtest." )
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_npc_brain failed: {ex.Message}" } );
}
}
/// <summary>
/// Build the NpcBrain component source. Everything here must be SANDBOX-LEGAL.
/// Movement uses only the confirmed NavMeshAgent.MoveTo(Vector3); perception
/// uses only Vector3.Dot/.Normal + scene.Trace.Ray(a,b).Run() + Scene.GetAllComponents.
/// FOV uses a baked cosine threshold (no trig in the sandbox).
/// When <paramref name="animate"/> is true the generated brain also caches a
/// CitizenAnimationHelper (off a SkinnedModelRenderer) and feeds it the NavMeshAgent
/// velocity each frame — sandbox-legal locomotion ported from BigfootBrain.cs (uses
/// Sandbox.Citizen + MathX, never System.Math).
/// </summary>
private static string BuildSource(
string className, string startState, bool networked, bool animate, string targetTagLiteral,
float moveSpeed, float chaseSpeed, float sightRange, float fovDegrees, float cosFov,
float eyeHeight, float hearingRadius, float giveUpTime, float searchRadius,
float waypointStop, bool canFlee, float fleeHealth )
{
string F( float v ) => NpcBrainHelpers.F( v );
// Host-authority guard line (networked) vs none (solo). The [Sync] on
// CurrentState lets proxies read the host's state for client-side animation.
var proxyGuard = networked ? "\t\tif ( IsProxy ) return; // host-authoritative — only the host thinks\n" : "";
var stateAttr = networked ? "[Sync] " : "";
var headerNote = networked
? "// Host-authoritative AI brain. Only the host runs the FSM; CurrentState is [Sync]'d\n// so proxy clients can animate the NPC. Needs an active network session (a no-session\n// solo playtest makes everything a proxy — generate with networked:false to iterate solo).\n"
: "// Solo / edit-scene AI brain (no networking guard). Ticks in a single-machine playtest.\n";
// ── Citizen locomotion animation (ported verbatim from the proven BigfootBrain.cs).
// Everything here is sandbox-legal: Sandbox.Citizen + GetOrAddComponent + the
// NavMeshAgent's own Velocity/WishVelocity, no System.Math. When animate:false these
// fragments are empty strings, so the generated brain is byte-for-byte the old one.
var animUsing = animate ? "using Sandbox.Citizen;\n" : "";
var animFields = animate
? "\n\t// Citizen locomotion. Drives the anim helper from the agent's velocity each frame so the\n" +
"\t// NPC walks/runs/idles instead of sliding in bind pose. Cached off the SkinnedModelRenderer\n" +
"\t// in OnStart (works for the source NPC AND its spawner clones — they each run OnStart).\n" +
"\tprivate CitizenAnimationHelper _anim;\n" +
"\tprivate SkinnedModelRenderer _renderer;\n"
: "";
// OnStart wiring. Wiring _anim.Target avoids a WithWishVelocity NRE (see SBOX_KNOWLEDGE.md).
var animOnStart = animate
? "\n\t\t// Locomotion animation. Find the SkinnedModelRenderer (this GO or a child), then\n" +
"\t\t// get-or-add a CitizenAnimationHelper and wire its Target — the helper NREs in\n" +
"\t\t// WithWishVelocity if Target is null. A Citizen .vmdl already has the locomotion\n" +
"\t\t// anim-graph, so once fed velocity it walks/runs/idles on its own.\n" +
"\t\t_renderer = GetComponent<SkinnedModelRenderer>() ?? GetComponentInChildren<SkinnedModelRenderer>();\n" +
"\t\tif ( _renderer.IsValid() )\n" +
"\t\t{\n" +
"\t\t\t_anim = GetOrAddComponent<CitizenAnimationHelper>();\n" +
"\t\t\t_anim.Target = _renderer;\n" +
"\t\t}\n"
: "";
// Per-frame drive call (placed at the end of OnUpdate) + the method body.
var animUpdateCall = animate ? "\t\tDriveAnimation();\n" : "";
var animMethod = animate
? "\n\t// ── Locomotion animation ────────────────────────────────────────────────────\n" +
"\t/// <summary>Feed the Citizen anim helper from the NavMeshAgent each frame so the NPC\n" +
"\t/// plays walk/run/idle instead of sliding in bind pose. WithVelocity drives the\n" +
"\t/// locomotion blend; WithWishVelocity drives lean/start-stop; IsGrounded keeps it out\n" +
"\t/// of the fall pose. Glance toward the chased target, else toward travel direction.</summary>\n" +
"\tprivate void DriveAnimation()\n" +
"\t{\n" +
"\t\tif ( _anim == null || !_anim.IsValid() ) return;\n" +
"\n" +
"\t\tvar velocity = _agent.Velocity;\n" +
"\t\t_anim.WithVelocity( velocity );\n" +
"\t\t_anim.WithWishVelocity( _agent.WishVelocity );\n" +
"\t\t_anim.IsGrounded = true;\n" +
"\n" +
"\t\tVector3 lookDir;\n" +
"\t\tif ( CurrentState == BrainState.Chase && _target.IsValid() )\n" +
"\t\t\tlookDir = ( _target.WorldPosition - WorldPosition ).WithZ( 0f );\n" +
"\t\telse\n" +
"\t\t\tlookDir = velocity.WithZ( 0f );\n" +
"\n" +
"\t\tif ( lookDir.Length > 1f )\n" +
"\t\t\t_anim.WithLook( lookDir.Normal, 1f, 0.6f, 0.2f );\n" +
"\t}\n"
: "";
return
$@"using Sandbox;
{animUsing}using System;
using System.Collections.Generic;
using System.Linq;
{headerNote}public sealed class {className} : Component
{{
public enum BrainState {{ Idle, Patrol, Wander, Chase, Search, Flee, Ambush }}
// ── Tunables (all [Property] so the bridge can set_property / tune later) ──
[Property] public BrainState StartState {{ get; set; }} = BrainState.{startState};
[Property] public float MoveSpeed {{ get; set; }} = {F( moveSpeed )};
[Property] public float ChaseSpeed {{ get; set; }} = {F( chaseSpeed )};
// Perception
[Property] public float SightRange {{ get; set; }} = {F( sightRange )};
// FovDegrees is the human-readable full cone angle. The actual gate compares a
// dot product against CosFovThreshold = cos(FovDegrees/2), which is baked here so
// the sandbox needs no trig. If you change FovDegrees at runtime, also update
// CosFovThreshold (tune_npc_perception / set_property), or call SetFov(...) below.
[Property] public float FovDegrees {{ get; set; }} = {F( fovDegrees )};
[Property] public float CosFovThreshold {{ get; set; }} = {F( cosFov )};
[Property] public float EyeHeight {{ get; set; }} = {F( eyeHeight )};
[Property] public float HearingRadius {{ get; set; }} = {F( hearingRadius )};
[Property] public string TargetTag {{ get; set; }} = ""{targetTagLiteral}"";
// Memory / timing
[Property] public float GiveUpTime {{ get; set; }} = {F( giveUpTime )};
[Property] public float SearchRadius {{ get; set; }} = {F( searchRadius )};
[Property] public float WaypointStopDistance {{ get; set; }} = {F( waypointStop )};
[Property] public bool PingPong {{ get; set; }} = false;
// Flee (health source is generic: the game sets CurrentHealthFrac 0..1, or
// override ShouldFlee() in a partial/subclass — no hard coupling to any HP comp).
[Property] public bool CanFlee {{ get; set; }} = {( canFlee ? "true" : "false" )};
[Property] public float FleeHealthFrac {{ get; set; }} = {F( fleeHealth )};
[Property] public float CurrentHealthFrac {{ get; set; }} = 1f;
// Patrol route (placed + wired by assign_patrol_route, or hand-set in editor).
[Property] public List<GameObject> Waypoints {{ get; set; }} = new();
// ── Runtime state ──
{stateAttr}public BrainState CurrentState {{ get; private set; }}
private GameObject _target;
private Vector3 _lastKnownPos;
private TimeSince _timeSinceSeen;
private Vector3 _wanderTarget;
private TimeSince _timeSinceWanderPick;
private int _waypointIndex;
private int _waypointDir = 1;
private NavMeshAgent _agent;
{animFields}
protected override void OnStart()
{{
_agent = GetOrAddComponent<NavMeshAgent>();
{animOnStart} CurrentState = StartState;
_timeSinceSeen = 999f;
_lastKnownPos = WorldPosition;
_wanderTarget = WorldPosition;
}}
protected override void OnUpdate()
{{
{proxyGuard} if ( _agent == null ) return;
Perceive();
Think();
Act();
{animUpdateCall} }}
{animMethod}
/// <summary>Recompute the FOV cosine from a degree value at runtime (no trig in
/// the sandbox: cos(x) via the half-angle identity from a normalized sweep is
/// overkill, so we keep it simple — set both together).</summary>
public void SetFov( float degrees, float cosThreshold )
{{
FovDegrees = degrees;
CosFovThreshold = cosThreshold;
}}
// ── Perception ────────────────────────────────────────────────────────────
private void Perceive()
{{
var eye = WorldPosition + Vector3.Up * EyeHeight;
var best = FindVisibleTarget( eye, out var sawSomething );
if ( best.IsValid() )
{{
_target = best;
_lastKnownPos = best.WorldPosition;
_timeSinceSeen = 0f;
return;
}}
// Passive hearing: a candidate within HearingRadius is ""heard"" (sets a
// last-known position to investigate) but is NOT treated as seen — so the
// NPC investigates rather than instantly aggroing.
var heard = FindNearestCandidate( WorldPosition, HearingRadius );
if ( heard.IsValid() )
_lastKnownPos = heard.WorldPosition;
// keep _target ref while it grows stale; _timeSinceSeen advances on its own.
}}
/// <summary>Pick the nearest candidate that passes range + FOV cone + LOS.</summary>
private GameObject FindVisibleTarget( Vector3 eye, out bool any )
{{
any = false;
GameObject bestGo = null;
float bestDist = float.MaxValue;
foreach ( var cand in Candidates() )
{{
var to = cand.WorldPosition - eye;
float dist = to.Length;
if ( dist > SightRange ) continue;
if ( dist < 0.01f ) continue;
var dir = to.Normal;
// FOV cone gate (cheap): dot >= cos(half-fov). No trig needed.
if ( Vector3.Dot( WorldRotation.Forward, dir ) < CosFovThreshold ) continue;
// Occlusion trace from the eye to the candidate. IgnoreGameObjectHierarchy
// excludes the NPC's own colliders so it can't ""see"" itself. Clear when the
// ray hits the candidate directly, hits nothing, or the first hit is
// essentially at the candidate (a child collider) — a distance test that
// needs no extra API. Anything blocking earlier (a tree/wall) fails LOS.
var tr = Scene.Trace.Ray( eye, cand.WorldPosition ).IgnoreGameObjectHierarchy( GameObject ).Run();
bool clear = !tr.Hit || tr.GameObject == cand || tr.Distance >= dist - 8f;
if ( !clear ) continue;
any = true;
if ( dist < bestDist ) {{ bestDist = dist; bestGo = cand; }}
}}
return bestGo;
}}
private GameObject FindNearestCandidate( Vector3 from, float maxDist )
{{
GameObject best = null;
float bestDist = maxDist;
foreach ( var cand in Candidates() )
{{
float d = Vector3.DistanceBetween( from, cand.WorldPosition );
if ( d <= bestDist ) {{ bestDist = d; best = cand; }}
}}
return best;
}}
/// <summary>Candidate targets = GameObjects tagged TargetTag, excluding self.
/// Uses Scene.GetAllComponents to enumerate, then filters by tag.</summary>
private IEnumerable<GameObject> Candidates()
{{
foreach ( var c in Scene.GetAllComponents<Collider>() )
{{
var go = c.GameObject;
if ( go == null || go == GameObject ) continue;
if ( !go.Tags.Has( TargetTag ) ) continue;
yield return go;
}}
}}
// ── Transition table ────────────────────────────────────────────────────
private void Think()
{{
bool canSee = _target.IsValid() && _timeSinceSeen < 0.1f;
if ( CanFlee && ShouldFlee() ) {{ CurrentState = BrainState.Flee; return; }}
switch ( CurrentState )
{{
case BrainState.Idle:
case BrainState.Patrol:
case BrainState.Wander:
case BrainState.Ambush:
if ( canSee ) CurrentState = BrainState.Chase;
break;
case BrainState.Chase:
if ( !canSee && _timeSinceSeen > 0.25f ) CurrentState = BrainState.Search;
break;
case BrainState.Search:
if ( canSee ) CurrentState = BrainState.Chase;
else if ( _timeSinceSeen > GiveUpTime ) {{ _target = null; CurrentState = StartState; }}
break;
case BrainState.Flee:
if ( !ShouldFlee() ) CurrentState = StartState;
break;
}}
}}
// ── Action per state ──────────────────────────────────────────────────────
private void Act()
{{
// Apply the desired locomotion speed (chase is faster). NavMeshAgent.MaxSpeed
// is the agent's speed cap (verified in the navmesh docs).
_agent.MaxSpeed = ( CurrentState == BrainState.Chase || CurrentState == BrainState.Flee ) ? ChaseSpeed : MoveSpeed;
switch ( CurrentState )
{{
case BrainState.Idle:
case BrainState.Ambush:
// Stand still and watch (perception still runs every tick).
_agent.Stop();
break;
case BrainState.Patrol:
PatrolStep();
break;
case BrainState.Wander:
WanderStep( WorldPosition, SearchRadius );
break;
case BrainState.Chase:
if ( _target.IsValid() )
_agent.MoveTo( _target.WorldPosition );
break;
case BrainState.Search:
if ( Vector3.DistanceBetween( WorldPosition, _lastKnownPos ) > WaypointStopDistance )
_agent.MoveTo( _lastKnownPos );
else
WanderStep( _lastKnownPos, SearchRadius );
break;
case BrainState.Flee:
FleeStep();
break;
}}
}}
private void PatrolStep()
{{
if ( Waypoints == null || Waypoints.Count == 0 ) return;
_waypointIndex = (int)MathX.Clamp( _waypointIndex, 0, Waypoints.Count - 1 );
var wp = Waypoints[_waypointIndex];
if ( !wp.IsValid() ) {{ AdvanceWaypoint(); return; }}
if ( Vector3.DistanceBetween( WorldPosition, wp.WorldPosition ) <= WaypointStopDistance )
AdvanceWaypoint();
else
_agent.MoveTo( wp.WorldPosition );
}}
private void AdvanceWaypoint()
{{
if ( Waypoints == null || Waypoints.Count <= 1 ) return;
if ( PingPong )
{{
if ( _waypointIndex + _waypointDir >= Waypoints.Count || _waypointIndex + _waypointDir < 0 )
_waypointDir = -_waypointDir;
_waypointIndex += _waypointDir;
}}
else
{{
_waypointIndex = ( _waypointIndex + 1 ) % Waypoints.Count;
}}
}}
private void WanderStep( Vector3 home, float radius )
{{
bool reached = Vector3.DistanceBetween( WorldPosition, _wanderTarget ) <= WaypointStopDistance;
if ( reached || _timeSinceWanderPick > 4f )
{{
// Pick a fresh point near home. Uses only confirmed APIs (Random.Shared
// + Vector3). The agent paths toward the nearest reachable point, so an
// occasional off-mesh pick is harmless. (For strictly-on-mesh wander,
// swap to Scene.NavMesh.GetRandomPoint(home, radius) once its return type
// is confirmed via describe_type.)
var off = new Vector3(
Random.Shared.Float( -radius, radius ),
Random.Shared.Float( -radius, radius ),
0f );
_wanderTarget = home + off;
_timeSinceWanderPick = 0f;
}}
_agent.MoveTo( _wanderTarget );
}}
private void FleeStep()
{{
// Move directly away from the last-known threat position.
var away = ( WorldPosition - _lastKnownPos ).Normal;
if ( away.Length < 0.01f ) away = WorldRotation.Forward;
_agent.MoveTo( WorldPosition + away * MathX.Clamp( SearchRadius, 100f, 2000f ) );
}}
/// <summary>Generic flee predicate. Driven by CurrentHealthFrac (the game sets
/// it 0..1). Override in a subclass/partial for game-specific logic (e.g. a
/// bomb-timer panic in RUN, or a camper-HP check in Sasquatched).</summary>
public bool ShouldFlee()
{{
return CanFlee && CurrentHealthFrac <= FleeHealthFrac;
}}
// ── Noise hook (pure C#; the game calls this where a noise happens) ─────────
// Example: NpcBrain.ReportNoise(flashlightPos, 800f) when a camper clicks a
// flashlight, or a gunshot in RUN. NPCs within radius investigate (Search).
public static void ReportNoise( Scene scene, Vector3 pos, float radius )
{{
if ( scene == null ) return;
foreach ( var brain in scene.GetAllComponents<{className}>() )
brain.HearNoise( pos, radius );
}}
public void HearNoise( Vector3 pos, float radius )
{{
if ( Vector3.DistanceBetween( WorldPosition, pos ) > radius ) return;
_lastKnownPos = pos;
if ( CurrentState != BrainState.Chase )
CurrentState = BrainState.Search;
}}
}}
";
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 2. place_patrol_route (scene-mutating)
// Create N waypoint empties (tagged), grouped under a parent route object,
// optionally snapped to the ground so they sit on the navmesh.
// ═══════════════════════════════════════════════════════════════════════════
public class PlacePatrolRouteHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "points", out var pts ) || pts.ValueKind != JsonValueKind.Array )
return Task.FromResult<object>( new { error = "points (Vector3[]) is required" } );
var rawPoints = new List<Vector3>();
foreach ( var e in pts.EnumerateArray() )
rawPoints.Add( ClaudeBridge.ParseVector3( e ) );
if ( rawPoints.Count < 2 )
return Task.FromResult<object>( new { error = "Provide at least 2 points for a patrol route" } );
var routeName = NpcBrainHelpers.Str( p, "name", "PatrolRoute" );
var tag = NpcBrainHelpers.Str( p, "tag", "waypoint" );
var snap = NpcBrainHelpers.Bool( p, "snapToGround", true );
try
{
// Resolve or create the route parent.
GameObject route = null;
if ( p.TryGetProperty( "parentId", out var pid ) && Guid.TryParse( pid.GetString(), out var parentGuid ) )
route = scene.Directory.FindByGuid( parentGuid );
if ( route == null )
{
route = scene.CreateObject( true );
route.Name = routeName;
// Place the parent at the centroid for a tidy hierarchy + easy framing.
var centroid = Vector3.Zero;
foreach ( var pt in rawPoints ) centroid += pt;
route.WorldPosition = centroid / rawPoints.Count;
}
var waypointIds = new List<string>( rawPoints.Count );
int i = 0;
foreach ( var pt in rawPoints )
{
var pos = pt;
if ( snap )
{
try
{
var tr = scene.Trace.Ray( pos + Vector3.Up * 2000f, pos + Vector3.Down * 20000f ).Run();
if ( tr.Hit ) pos = new Vector3( pos.x, pos.y, tr.HitPosition.z );
}
catch { /* keep the raw point on trace failure */ }
}
var wp = scene.CreateObject( true );
wp.Name = $"{routeName}_WP{i}";
wp.WorldPosition = pos;
wp.Tags.Add( tag );
wp.SetParent( route, keepWorldPosition: true );
waypointIds.Add( wp.Id.ToString() );
i++;
}
return Task.FromResult<object>( new
{
placed = true,
routeId = route.Id.ToString(),
routeName = route.Name,
waypointIds,
count = waypointIds.Count,
snappedToGround = snap,
note = "Wire these into an NpcBrain with assign_patrol_route (pass routeId or waypointIds). " +
"Validate connectivity with get_navmesh_path between consecutive waypoints (catches a point in a wall)."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"place_patrol_route failed: {ex.Message}" } );
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. assign_patrol_route (scene-mutating)
// Wire a placed route (or an arbitrary GUID list) into a List<GameObject>
// property (default "Waypoints") on a target NPC's component. This is the
// list-of-GameObject-refs case plain set_property can't express.
// ═══════════════════════════════════════════════════════════════════════════
public class AssignPatrolRouteHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
return Task.FromResult<object>( new { error = "npcId (GameObject GUID holding the NpcBrain) is required" } );
var npc = scene.Directory.FindByGuid( npcGuid );
if ( npc == null )
return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );
var property = NpcBrainHelpers.Str( p, "property", "Waypoints" );
try
{
// ── Gather the ordered waypoint GameObjects: explicit waypointIds win,
// else the children (hierarchy order) of routeId.
var waypoints = new List<GameObject>();
if ( p.TryGetProperty( "waypointIds", out var wpArr ) && wpArr.ValueKind == JsonValueKind.Array )
{
foreach ( var e in wpArr.EnumerateArray() )
if ( Guid.TryParse( e.GetString(), out var g ) )
{
var go = scene.Directory.FindByGuid( g );
if ( go != null ) waypoints.Add( go );
}
}
else if ( p.TryGetProperty( "routeId", out var routeEl ) && Guid.TryParse( routeEl.GetString(), out var routeGuid ) )
{
var route = scene.Directory.FindByGuid( routeGuid );
if ( route == null )
return Task.FromResult<object>( new { error = $"Route GameObject not found: {routeEl.GetString()}" } );
foreach ( var child in route.Children )
waypoints.Add( child );
}
else
{
return Task.FromResult<object>( new { error = "Provide waypointIds (GUID[]) or routeId (route parent GUID)" } );
}
if ( waypoints.Count == 0 )
return Task.FromResult<object>( new { error = "No valid waypoints resolved from the given ids/route" } );
// ── Resolve the component + property and set the List<GameObject>.
// SetValue accepts a List<GameObject>; we hand it the concrete list
// (matches how the editor serializes [Property] lists of refs).
var comp = NpcBrainHelpers.SetComponentProperty( npc, property, waypoints );
if ( comp == null )
return Task.FromResult<object>( new { error = $"No component on the NPC exposes a '{property}' property (expected an NpcBrain with a List<GameObject> {property})" } );
return Task.FromResult<object>( new
{
assigned = true,
npcId = npcEl.GetString(),
component = comp.GetType().Name,
property,
count = waypoints.Count,
note = "List<GameObject> refs may read back as handles/GUIDs via get_property — trust this count, or confirm patrol in play mode."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"assign_patrol_route failed: {ex.Message}" } );
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 4. create_npc_spawner (code-gen; scene-mutating)
// Generate a spawner Component that clones an NPC prefab over time / in
// escalating waves at spawn points, capped by maxAlive. Host-authoritative
// when networked (NetworkSpawn, guarded).
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNpcSpawnerHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var name = NpcBrainHelpers.Str( p, "name", "NpcSpawner" );
var directory = NpcBrainHelpers.Str( p, "directory", "Code" );
var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )
return Task.FromResult<object>( new { error = pathErr } );
if ( File.Exists( fullPath ) )
return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );
var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );
var mode = NpcBrainHelpers.Str( p, "mode", "waves" ).ToLowerInvariant();
if ( mode != "continuous" && mode != "waves" && mode != "burst" ) mode = "waves";
var modeEnum = mode == "continuous" ? "Continuous" : ( mode == "burst" ? "Burst" : "Waves" );
var count = NpcBrainHelpers.Int( p, "count", 5 );
var interval = NpcBrainHelpers.Float( p, "interval", 8f );
var waveCount = NpcBrainHelpers.Int( p, "waveCount", 3 );
var waveGrowth = NpcBrainHelpers.Float( p, "waveGrowth", 1f );
var radius = NpcBrainHelpers.Float( p, "radius", 200f );
var maxAlive = NpcBrainHelpers.Int( p, "maxAlive", 12 );
var networked = NpcBrainHelpers.Bool( p, "networked", true );
var code = BuildSpawnerSource( className, modeEnum, networked,
count, interval, waveCount, waveGrowth, radius, maxAlive );
Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
File.WriteAllText( fullPath, code );
var props = new[]
{
"NpcPrefab","SpawnPoints","Mode","Count","Interval","WaveCount",
"WaveGrowth","Radius","MaxAlive","AutoStart"
};
return Task.FromResult<object>( new
{
created = true,
path = $"{directory}/{fileName}",
className,
mode,
networked,
propertyNames = props,
note = "Set NpcPrefab via set_prefab_ref. Add spawn points by reusing place_patrol_route (a route of empties) then " +
"assign_patrol_route with property=\"SpawnPoints\", or set SpawnPoints by hand. " +
( networked
? "Networked spawns use NetworkSpawn() and are host-only (guarded) — needs a host session."
: "Solo build: plain Clone() (no NetworkSpawn)." ) +
" Verify by watching GameObject count over time in play mode (get_scene_hierarchy deltas)."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_npc_spawner failed: {ex.Message}" } );
}
}
private static string BuildSpawnerSource(
string className, string modeEnum, bool networked,
int count, float interval, int waveCount, float waveGrowth, float radius, int maxAlive )
{
string F( float v ) => NpcBrainHelpers.F( v );
var proxyGuard = networked ? "\t\tif ( IsProxy ) return; // host spawns authoritatively\n" : "";
var headerNote = networked
? "// Host-authoritative spawner. Only the host spawns (NetworkSpawn so clients see the\n// NPCs). Needs an active network session.\n"
: "// Solo / edit-scene spawner (plain Clone, no networking).\n";
// Spawn idiom: clone the prefab, place it, and (networked) NetworkSpawn in a
// try/catch — the verified solo-safe idiom (NetworkSpawn throws with no session).
var spawnBody = networked
?
@" var go = NpcPrefab.Clone( pos );
try { go.NetworkSpawn(); } catch { /* no session — fall back to a local object */ }
_alive.Add( go );"
:
@" var go = NpcPrefab.Clone( pos );
_alive.Add( go );";
return
$@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
{headerNote}public sealed class {className} : Component
{{
public enum SpawnMode {{ Continuous, Waves, Burst }}
[Property] public GameObject NpcPrefab {{ get; set; }}
[Property] public List<GameObject> SpawnPoints {{ get; set; }} = new();
[Property] public SpawnMode Mode {{ get; set; }} = SpawnMode.{modeEnum};
[Property] public int Count {{ get; set; }} = {count}; // per-wave (Waves) or total (Burst/Continuous batch)
[Property] public float Interval {{ get; set; }} = {F( interval )}; // seconds between spawns (Continuous) or waves (Waves)
[Property] public int WaveCount {{ get; set; }} = {waveCount};
[Property] public float WaveGrowth {{ get; set; }} = {F( waveGrowth )}; // multiply Count each wave (>1 = escalating)
[Property] public float Radius {{ get; set; }} = {F( radius )}; // random scatter around a spawn point
[Property] public int MaxAlive {{ get; set; }} = {maxAlive}; // concurrency cap
[Property] public bool AutoStart {{ get; set; }} = true;
private readonly List<GameObject> _alive = new();
private TimeSince _timeSinceSpawn;
private int _wavesDone;
private float _currentWaveCount;
private bool _started;
protected override void OnStart()
{{
_currentWaveCount = Count;
_timeSinceSpawn = Interval; // fire promptly on the first eligible tick
if ( AutoStart ) _started = true;
}}
protected override void OnUpdate()
{{
{proxyGuard} if ( !_started || NpcPrefab == null ) return;
// Drop dead/destroyed NPCs from the live list so MaxAlive is accurate.
_alive.RemoveAll( g => !g.IsValid() );
switch ( Mode )
{{
case SpawnMode.Burst:
SpawnBatch( (int)_currentWaveCount );
_started = false; // one-shot
break;
case SpawnMode.Continuous:
if ( _timeSinceSpawn >= Interval )
{{
_timeSinceSpawn = 0f;
TrySpawnOne();
}}
break;
case SpawnMode.Waves:
if ( _wavesDone >= WaveCount ) {{ _started = false; break; }}
if ( _timeSinceSpawn >= Interval )
{{
_timeSinceSpawn = 0f;
SpawnBatch( (int)_currentWaveCount );
_wavesDone++;
_currentWaveCount = MathX.Clamp( _currentWaveCount * WaveGrowth, 1f, 9999f );
}}
break;
}}
}}
private void SpawnBatch( int n )
{{
for ( int i = 0; i < n; i++ )
if ( !TrySpawnOne() ) break;
}}
private bool TrySpawnOne()
{{
if ( _alive.Count >= MaxAlive ) return false;
var pos = PickSpawnPos();
{spawnBody}
return true;
}}
private Vector3 PickSpawnPos()
{{
var basePos = WorldPosition;
if ( SpawnPoints != null && SpawnPoints.Count > 0 )
{{
var pick = SpawnPoints[Random.Shared.Next( 0, SpawnPoints.Count )];
if ( pick.IsValid() ) basePos = pick.WorldPosition;
}}
var off = new Vector3(
Random.Shared.Float( -Radius, Radius ),
Random.Shared.Float( -Radius, Radius ),
0f );
return basePos + off;
}}
}}
";
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 5. simulate_npc_perception (READ-ONLY — NOT scene-mutating)
// Run the EXACT LOS check an NpcBrain would, in edit mode, without play.
// FOV cone (dot vs CosFovThreshold) + range + occlusion trace. Reports the
// result AND why — the keystone edit-mode verifier for the perception layer.
// ═══════════════════════════════════════════════════════════════════════════
public class SimulateNpcPerceptionHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null )
return Task.FromResult<object>( new { error = "No active scene" } );
if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
return Task.FromResult<object>( new { error = "npcId (GameObject GUID with an NpcBrain) is required" } );
var npc = scene.Directory.FindByGuid( npcGuid );
if ( npc == null )
return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );
try
{
// ── Read perception params from the NPC's brain if present, else fall back
// to spec defaults / explicit overrides in the call. Matches the brain by
// CAPABILITY (exposes SightRange+FovDegrees) or a "...Brain" type name — NOT
// just the literal type name "NpcBrain" — so a custom-named brain
// (e.g. BigfootBrain) is read instead of silently using defaults.
var brain = NpcBrainHelpers.FindPerceptionBrain( npc );
// `var` (never name TypeDescription) — its namespace isn't guaranteed importable here.
var brainTd = brain != null ? Game.TypeLibrary.GetType( brain.GetType().Name ) : null;
float ReadBrainFloat( string name, float fallback )
{
if ( brain == null || brainTd == null ) return fallback;
var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
if ( pd == null ) return fallback;
try
{
var v = pd.GetValue( brain );
if ( v is float f ) return f;
if ( v != null && float.TryParse( v.ToString(), out var fp ) ) return fp;
}
catch { }
return fallback;
}
string ReadBrainString( string name, string fallback )
{
if ( brain == null || brainTd == null ) return fallback;
var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
try { return pd?.GetValue( brain )?.ToString() ?? fallback; } catch { return fallback; }
}
// Explicit overrides take precedence over brain-read values.
float sightRange = NpcBrainHelpers.Float( p, "sightRange", ReadBrainFloat( "SightRange", 1500f ) );
float fovDegrees = NpcBrainHelpers.Float( p, "fovDegrees", ReadBrainFloat( "FovDegrees", 110f ) );
float eyeHeight = NpcBrainHelpers.Float( p, "eyeHeight", ReadBrainFloat( "EyeHeight", 64f ) );
string targetTag = NpcBrainHelpers.Str( p, "targetTag", ReadBrainString( "TargetTag", "player" ) );
// Use the brain's baked CosFovThreshold if available (keeps this query in
// lockstep with the generated component); else compute it here.
float cosFov = ReadBrainFloat( "CosFovThreshold", float.NaN );
if ( float.IsNaN( cosFov ) ) cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );
// ── Resolve the target point: explicit targetId or a raw point.
GameObject targetGo = null;
Vector3 targetPos;
if ( p.TryGetProperty( "targetId", out var tEl ) && Guid.TryParse( tEl.GetString(), out var tGuid ) )
{
targetGo = scene.Directory.FindByGuid( tGuid );
if ( targetGo == null )
return Task.FromResult<object>( new { error = $"Target GameObject not found: {tEl.GetString()}" } );
targetPos = targetGo.WorldPosition;
}
else if ( p.TryGetProperty( "point", out var ptEl ) )
{
targetPos = ClaudeBridge.ParseVector3( ptEl );
}
else
{
return Task.FromResult<object>( new { error = "Provide targetId (GameObject GUID) or point (Vector3)" } );
}
var eye = npc.WorldPosition + Vector3.Up * eyeHeight;
var to = targetPos - eye;
float distance = to.Length;
// Degenerate: target is essentially at the eye.
if ( distance < 0.01f )
{
return Task.FromResult<object>( new
{
canSee = true, inRange = true, inFov = true, losBlocked = false,
distance, angleDeg = 0.0,
eye = new { eye.x, eye.y, eye.z },
note = "Target coincides with the NPC eye position."
} );
}
var dir = to.Normal;
float dot = Vector3.Dot( npc.WorldRotation.Forward, dir );
// angle (degrees) for human-readable output. MathF is fine here (editor).
float angleDeg = MathF.Acos( Math.Clamp( dot, -1f, 1f ) ) * ( 180f / MathF.PI );
bool inRange = distance <= sightRange;
bool inFov = dot >= cosFov;
// Occlusion trace from the eye toward the target. IgnoreGameObjectHierarchy
// drops the NPC's own colliders (confirmed builder), so any hit is an
// external object. It blocks LOS only if it's clearly before the target
// (hit on the target itself, or a hit at/after the target distance, is not
// a blocker). Distance test only — no GameObject.Root needed.
bool losBlocked = false;
object blockedBy = null;
var tr = scene.Trace.Ray( eye, targetPos ).IgnoreGameObjectHierarchy( npc ).Run();
if ( tr.Hit )
{
bool hitIsTarget = ( targetGo != null && tr.GameObject == targetGo )
|| tr.Distance >= distance - 8f; // a hit at/after the target point isn't a blocker
if ( !hitIsTarget )
{
losBlocked = true;
blockedBy = new { id = tr.GameObject?.Id.ToString(), name = tr.GameObject?.Name };
}
}
bool tagMatch = targetGo == null || targetGo.Tags.Has( targetTag );
bool canSee = inRange && inFov && !losBlocked && tagMatch;
return Task.FromResult<object>( new
{
canSee,
inRange,
inFov,
losBlocked,
blockedBy,
tagMatch,
distance,
angleDeg = (double)angleDeg,
fovHalfAngleDeg = (double)( fovDegrees * 0.5f ),
sightRange,
targetTag,
eye = new { eye.x, eye.y, eye.z },
brainComponent = brain?.GetType().Name,
note = brain == null
? "No perception brain found on this GameObject — used spec defaults / call overrides for the perception params."
: $"Read perception params from the '{brain.GetType().Name}' component's own SightRange/FovDegrees/EyeHeight/TargetTag (call params override). canSee mirrors what the generated brain computes."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"simulate_npc_perception failed: {ex.Message}" } );
}
}
}
using System;
namespace SboxMcp.Registry;
public enum ToolCategory
{
Scene,
GameObject,
Component,
Prefab,
Asset,
ModelDoc,
AnimGraph,
ShaderGraph,
ActionGraph,
Code,
Editor,
Retargeter,
Cloud,
Imported
}
/// <summary>
/// Marks a static method as an MCP tool. The registry reflects the method's
/// parameters into a JSON Schema and exposes it via tools/list.
/// </summary>
[AttributeUsage( AttributeTargets.Method )]
public sealed class McpToolAttribute : Attribute
{
public string Name { get; }
public string Description { get; }
public ToolCategory Category { get; }
/// <summary>Write tools are subject to the permission gate (approve-writes / read-only modes).</summary>
public bool Writes { get; init; }
/// <summary>
/// Optional requirement key (e.g. an integration's library ident). The host
/// resolves it via ToolRegistry.RequirementResolver; unresolved tools are
/// hidden from clients and shown disabled in the tool browser.
/// </summary>
public string Requires { get; init; }
/// <summary>
/// Ships disabled; the user must enable it in the tool browser. Used for
/// tools with external effects (e.g. downloading cloud assets).
/// </summary>
public bool DisabledByDefault { get; init; }
public McpToolAttribute( string name, string description, ToolCategory category )
{
Name = name;
Description = description;
Category = category;
}
}
/// <summary>
/// Optional description for a tool parameter, surfaced in the JSON Schema.
/// </summary>
[AttributeUsage( AttributeTargets.Parameter )]
public sealed class DescAttribute : Attribute
{
public string Text { get; }
public DescAttribute( string text ) { Text = text; }
}
/// <summary>
/// Thrown when tool arguments are missing or cannot be bound; surfaced to the
/// MCP client as an isError tool result.
/// </summary>
public sealed class ToolArgumentException : Exception
{
public ToolArgumentException( string message, Exception inner = null ) : base( message, inner ) { }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using SboxMcp.Server;
namespace SboxMcp.Registry;
/// <summary>
/// A discovered [McpTool] method, with its generated descriptor and an
/// argument-binding invoker.
/// </summary>
public sealed class RegisteredTool
{
public McpToolAttribute Meta { get; }
public MethodInfo Method { get; }
public McpToolDescriptor Descriptor { get; }
/// <summary>
/// Why this tool cannot run right now ("Disabled", "Not Installed", ...),
/// or null when it is available. Evaluated live so user toggles and
/// integrations installed mid-session apply without a restart.
/// </summary>
public string UnavailableReason
{
get
{
if ( ToolRegistry.DisabledResolver?.Invoke( this ) ?? Meta.DisabledByDefault )
return "Disabled";
return Meta.Requires is null ? null : ToolRegistry.RequirementResolver?.Invoke( Meta.Requires );
}
}
public bool IsAvailable => UnavailableReason is null;
internal RegisteredTool( McpToolAttribute meta, MethodInfo method )
{
Meta = meta;
Method = method;
Descriptor = new McpToolDescriptor( meta.Name, BuildDescription( meta ), SchemaGenerator.ForMethod( method ) );
}
static string BuildDescription( McpToolAttribute meta ) =>
meta.Writes ? $"{meta.Description} (modifies project state)" : meta.Description;
/// <summary>
/// Binds JSON arguments to the method's parameters by name and invokes it.
/// Throws ToolArgumentException on missing/unbindable arguments.
/// </summary>
public object Invoke( JsonElement? args )
{
var parameters = Method.GetParameters();
var bound = new object[parameters.Length];
for ( var i = 0; i < parameters.Length; i++ )
{
var p = parameters[i];
// JsonElement params accept explicit null (e.g. to clear a reference
// property); for typed params null falls through to the default
if ( args is { ValueKind: JsonValueKind.Object } a && a.TryGetProperty( p.Name, out var value )
&& (value.ValueKind != JsonValueKind.Null || p.ParameterType == typeof( JsonElement )) )
{
try
{
bound[i] = p.ParameterType == typeof( JsonElement )
? value.Clone()
: value.Deserialize( p.ParameterType, ToolRegistry.BindOptions );
}
catch ( Exception e ) when ( e is JsonException or NotSupportedException )
{
throw new ToolArgumentException(
$"Argument '{p.Name}' could not be read as {p.ParameterType.Name}: {e.Message}", e );
}
}
else if ( p.HasDefaultValue )
{
bound[i] = p.DefaultValue;
}
else
{
throw new ToolArgumentException( $"Missing required argument '{p.Name}'" );
}
}
try
{
return Method.Invoke( null, bound );
}
catch ( TargetInvocationException e ) when ( e.InnerException is not null )
{
throw e.InnerException;
}
}
}
/// <summary>
/// Discovers [McpTool] static methods and serves them to the MCP server.
/// </summary>
public sealed class ToolRegistry
{
/// <summary>
/// Maps a tool's Requires key to an unavailability reason (short, e.g.
/// "Not Installed") or null when the requirement is satisfied. Null
/// resolver = everything available.
/// </summary>
public static Func<string, string> RequirementResolver { get; set; }
/// <summary>
/// Whether the user has disabled this tool. Null resolver = only
/// DisabledByDefault applies.
/// </summary>
public static Func<RegisteredTool, bool> DisabledResolver { get; set; }
internal static readonly JsonSerializerOptions BindOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
static readonly JsonSerializerOptions ResultOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
readonly List<RegisteredTool> _tools = new();
readonly Dictionary<string, RegisteredTool> _byName = new( StringComparer.Ordinal );
public IReadOnlyList<RegisteredTool> Tools => _tools;
public void AddAssembly( Assembly assembly )
{
var methods = assembly.GetTypes()
.Where( t => t.IsClass )
.SelectMany( t => t.GetMethods( BindingFlags.Public | BindingFlags.Static ) )
.Select( m => (Method: m, Meta: m.GetCustomAttribute<McpToolAttribute>()) )
.Where( x => x.Meta is not null )
.OrderBy( x => x.Meta.Name, StringComparer.Ordinal );
foreach ( var (method, meta) in methods )
{
if ( _byName.ContainsKey( meta.Name ) )
continue;
var tool = new RegisteredTool( meta, method );
_tools.Add( tool );
_byName[meta.Name] = tool;
}
}
public RegisteredTool Find( string name ) => _byName.GetValueOrDefault( name );
/// <summary>
/// Registers an arbitrary public static method (from another library) as a
/// tool. Returns null when the name is already taken.
/// </summary>
public RegisteredTool AddImported( string name, string description, ToolCategory category, MethodInfo method )
{
if ( _byName.ContainsKey( name ) )
return null;
var meta = new McpToolAttribute( name, description, category ) { Writes = true };
var tool = new RegisteredTool( meta, method );
_tools.Add( tool );
_byName[name] = tool;
return tool;
}
public void Remove( string name )
{
if ( _byName.Remove( name, out var tool ) )
_tools.Remove( tool );
}
/// <summary>
/// Converts a tool's return value to the text sent back to the client.
/// </summary>
public static string FormatResult( object result ) => result switch
{
null => """{ "ok": true }""",
string s => s,
_ => JsonSerializer.Serialize( result, ResultOptions )
};
}
using System;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.ToolHelpers;
namespace SboxMcp.Tools;
public static class PrefabTools
{
[McpTool( "prefab_instantiate", "Instantiates a prefab into the active scene.", ToolCategory.Prefab, Writes = true )]
public static object Instantiate(
[Desc( "Prefab asset path, e.g. 'prefabs/door.prefab'" )] string prefabPath,
[Desc( "World position [x, y, z]" )] float[] position = null )
{
var session = RequireSession();
var prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath )
?? throw new InvalidOperationException( $"No prefab at '{prefabPath}' - use asset_search with assetType 'prefab'" );
var prefabScene = SceneUtility.GetPrefabScene( prefabFile )
?? throw new InvalidOperationException( $"Prefab '{prefabPath}' could not be loaded" );
using var undo = session.UndoScope( $"MCP: instantiate {prefabPath}" ).WithGameObjectCreations().Push();
var transform = position is null
? global::Transform.Zero
: new Transform( ToVector3( position, "position" ) );
var instance = prefabScene.Clone( transform );
return Describe( instance );
}
[McpTool( "prefab_create_from_gameobject", "Turns a GameObject (and its children) into a reusable .prefab asset; the original becomes an instance of it.", ToolCategory.Prefab, Writes = true )]
public static object CreateFromGameObject(
[Desc( "GameObject id or unique name" )] string gameObject,
[Desc( "Output path ending in .prefab, e.g. 'prefabs/door.prefab'" )] string prefabPath )
{
if ( !prefabPath.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) )
throw new ArgumentException( "prefabPath must end in .prefab" );
var session = RequireSession();
var go = FindGameObject( gameObject );
var absolute = AssetTools.ResolveNewAssetPath( prefabPath );
if ( System.IO.File.Exists( absolute ) )
throw new InvalidOperationException( $"'{prefabPath}' already exists" );
System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );
using var undo = session.UndoScope( $"MCP: create prefab {prefabPath}" )
.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();
EditorUtility.Prefabs.ConvertGameObjectToPrefab( go, absolute );
return new { created = prefabPath, instanceId = go.Id };
}
[McpTool( "prefab_break_instance", "Unlinks a prefab instance so it becomes plain GameObjects.", ToolCategory.Prefab, Writes = true )]
public static object BreakInstance( [Desc( "GameObject id or unique name of the prefab instance root" )] string gameObject )
{
var session = RequireSession();
var go = FindGameObject( gameObject );
if ( !go.IsPrefabInstance )
throw new InvalidOperationException( $"'{go.Name}' is not a prefab instance" );
using var undo = session.UndoScope( "MCP: break prefab instance" )
.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();
go.BreakFromPrefab();
return Describe( go );
}
[McpTool( "prefab_update_from_prefab", "Re-syncs a prefab instance from its source prefab file.", ToolCategory.Prefab, Writes = true )]
public static object UpdateFromPrefab( [Desc( "GameObject id or unique name of the prefab instance root" )] string gameObject )
{
var session = RequireSession();
var go = FindGameObject( gameObject );
if ( !go.IsPrefabInstance )
throw new InvalidOperationException( $"'{go.Name}' is not a prefab instance" );
using var undo = session.UndoScope( "MCP: update from prefab" )
.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();
go.UpdateFromPrefab();
return Describe( go );
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Sandbox;
using SboxMcp.Integration;
namespace SboxMcp.UI;
/// <summary>
/// Pick public static methods from installed libraries (and other loaded
/// code) to expose as MCP tools. Searchable; libraries are listed separately
/// from everything else. Choices apply immediately and persist.
/// </summary>
public class ImportToolsDialog : Dialog
{
readonly LineEdit _search;
readonly ScrollArea _scroll;
public ImportToolsDialog( Widget parent ) : base( parent )
{
Window.WindowTitle = "Import Tools From Library";
Window.SetWindowIcon( "library_add" );
Window.SetModal( true, true );
Window.MinimumWidth = 560;
Window.MinimumHeight = 480;
Layout = Layout.Column();
Layout.Margin = 16;
Layout.Spacing = 8;
var hint = Layout.Add( new Label(
"Expose public static methods from installed libraries as MCP tools. "
+ "Imported tools persist, re-bind every session, and are write-gated by approvals.", this ) );
hint.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px;" );
hint.WordWrap = true;
_search = Layout.Add( new LineEdit( this ) { PlaceholderText = "Search methods, types or libraries..." } );
_search.TextEdited += _ => Rebuild();
_scroll = new ScrollArea( this );
_scroll.Canvas = new Widget( _scroll );
_scroll.Canvas.Layout = Layout.Column();
_scroll.Canvas.Layout.Spacing = 2;
_scroll.Canvas.Layout.Margin = 4;
_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
Layout.Add( _scroll, 1 );
var buttons = Layout.AddRow();
buttons.AddStretchCell();
var done = buttons.Add( new Button.Primary( "Done" ) { Icon = "check" } );
done.Clicked = Close; // Dialog.Close closes the host window (Destroy leaves it black)
Rebuild();
}
void Rebuild()
{
var canvas = _scroll.Canvas;
canvas.Layout.Clear( true );
var query = _search.Text;
var candidates = ToolImporter.CandidateAssemblies().ToList();
AddSection( canvas, "Libraries", "extension",
candidates.Where( ToolImporter.IsLibraryAssembly ).ToList(), query );
AddSection( canvas, "Project & Other", "folder",
candidates.Where( a => !ToolImporter.IsLibraryAssembly( a ) ).ToList(), query );
canvas.Layout.AddStretchCell();
}
void AddSection( Widget canvas, string title, string icon, List<Assembly> assemblies, string query )
{
var header = canvas.Layout.Add( new Label( title, canvas ) );
header.SetStyles( $"color: {Theme.Blue.Hex}; font-size: 12px; font-weight: 700; margin-top: 8px;" );
var any = false;
foreach ( var assembly in assemblies )
{
var methods = ToolImporter.CandidateMethods( assembly )
.Where( m => Matches( assembly, m, query ) )
.Take( 60 )
.ToList();
if ( methods.Count == 0 )
continue;
any = true;
var name = canvas.Layout.Add( new Label( ToolImporter.FriendlyName( assembly ), canvas ) );
name.SetStyles( $"color: {Theme.Text.Hex}; font-size: 11px; font-weight: 600; margin-top: 4px; margin-left: 6px;" );
foreach ( var method in methods )
{
var parameters = string.Join( ", ", method.GetParameters().Select( p => p.Name ) );
var check = canvas.Layout.Add( new Checkbox( $"{method.DeclaringType?.Name}.{method.Name}({parameters})", canvas )
{
Value = ToolImporter.IsImported( method )
} );
check.ToolTip = method.DeclaringType?.FullName;
var captured = method;
check.Clicked = () =>
{
if ( check.Value )
ToolImporter.Import( captured );
else
ToolImporter.Unimport( captured );
};
}
}
if ( !any )
{
var empty = canvas.Layout.Add( new Label(
string.IsNullOrWhiteSpace( query ) ? "Nothing importable found." : "No matches.", canvas ) );
empty.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px; margin-left: 6px;" );
}
}
static bool Matches( Assembly assembly, MethodInfo method, string query )
{
if ( string.IsNullOrWhiteSpace( query ) )
return true;
return method.Name.Contains( query, StringComparison.OrdinalIgnoreCase )
|| (method.DeclaringType?.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) ?? false)
|| ToolImporter.FriendlyName( assembly ).Contains( query, StringComparison.OrdinalIgnoreCase );
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Integration;
using SboxMcp.Registry;
namespace SboxMcp.UI;
/// <summary>
/// Searchable, category-filterable browser of every tool the server exposes.
/// Doubles as documentation.
/// </summary>
public class ToolsPage : Widget
{
readonly LineEdit _search;
readonly List<CategoryChip> _chips = new();
readonly ScrollArea _scroll;
int _builtSignature = -1;
public ToolsPage( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 12;
Layout.Spacing = 8;
var searchRow = Layout.AddRow();
searchRow.Spacing = 6;
_search = searchRow.Add( new LineEdit( this ) { PlaceholderText = "Search tools..." }, 1 );
_search.TextEdited += _ => Rebuild();
var import = searchRow.Add( new Button( "Import Tools", "library_add" ) );
import.ToolTip = "Expose public static methods from other installed libraries as MCP tools";
import.Clicked = () => new ImportToolsDialog( this ).Show();
// FlowRow wraps the chips to new lines on narrow docks instead of
// letting them overlap
var chipFlow = Layout.Add( new FlowRow( this ) );
foreach ( var category in Enum.GetValues<ToolCategory>() )
{
var chip = new CategoryChip( category, chipFlow, clickable: true );
chip.OnToggled = Rebuild;
_chips.Add( chip );
chipFlow.AddItem( chip );
}
_scroll = new ScrollArea( this );
_scroll.Canvas = new Widget( _scroll );
_scroll.Canvas.Layout = Layout.Column();
_scroll.Canvas.Layout.Spacing = 2;
_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
Layout.Add( _scroll, 1 );
Rebuild();
}
/// <summary>
/// The dock restores before McpHost initializes, so the registry is empty
/// at construction time - poll until tools appear.
/// </summary>
public void Tick()
{
var sig = Signature();
if ( sig == _builtSignature )
return;
Rebuild();
}
static int Signature()
{
var tools = McpHost.Registry?.Tools;
return tools is null ? 0 : tools.Count * 1000 + tools.Count( t => t.IsAvailable );
}
void Rebuild()
{
_builtSignature = Signature();
var canvas = _scroll.Canvas;
canvas.Layout.Clear( true );
var query = _search.Text;
var enabled = _chips.Where( c => c.Toggled ).Select( c => c.Category ).ToHashSet();
var tools = (McpHost.Registry?.Tools ?? (IReadOnlyList<RegisteredTool>)Array.Empty<RegisteredTool>())
.Where( t => enabled.Contains( t.Meta.Category ) )
.Where( t => string.IsNullOrWhiteSpace( query )
|| t.Meta.Name.Contains( query, StringComparison.OrdinalIgnoreCase )
|| t.Meta.Description.Contains( query, StringComparison.OrdinalIgnoreCase ) )
.ToList();
var count = canvas.Layout.Add( new Label( $"{tools.Count} tools", canvas ) );
count.SetStyles( $"color: {Palette.TextDim.Hex}; font-size: 10px;" );
foreach ( var tool in tools )
canvas.Layout.Add( new ToolRow( tool, canvas ) );
canvas.Layout.AddStretchCell();
}
}
/// <summary>
/// One tool entry: name (mono), write badge, wrapped description.
/// </summary>
public class ToolRow : Widget
{
const float ToggleWidth = 40;
readonly RegisteredTool _tool;
public ToolRow( RegisteredTool tool, Widget parent ) : base( parent )
{
_tool = tool;
FixedHeight = 40;
ToolTip = tool.Meta.Description + "\n\nClick the toggle to enable/disable this tool.";
}
bool UserDisabled => McpSettings.GetToolDisabledOverride( _tool.Meta.Name ) ?? _tool.Meta.DisabledByDefault;
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
// the toggle lives in the right strip of the row
if ( e.LocalPosition.x < LocalRect.Right - ToggleWidth )
return;
McpSettings.SetToolDisabled( _tool.Meta.Name, !UserDisabled );
Update();
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
var unavailable = _tool.UnavailableReason;
var disabled = unavailable is not null;
var accent = Palette.For( _tool.Meta.Category );
if ( disabled )
accent = accent.WithAlpha( 0.35f );
if ( Paint.HasMouseOver && !disabled )
{
Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
Paint.DrawRect( LocalRect, 5 );
}
// category color tick
Paint.SetBrush( accent );
Paint.DrawRect( new Rect( LocalRect.Left + 2, LocalRect.Top + 8, 3, LocalRect.Height - 16 ), 1.5f );
// name
Paint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.6f ) : Palette.TextBright );
Paint.SetFont( "Consolas", 8, 600 );
var nameWidth = Paint.MeasureText( _tool.Meta.Name ).x;
Paint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 4, nameWidth + 4, 14 ), _tool.Meta.Name, TextFlag.LeftCenter );
var badgeLeft = LocalRect.Left + 20 + nameWidth;
// writes badge
if ( _tool.Meta.Writes && !disabled )
{
var badge = new Rect( badgeLeft, LocalRect.Top + 5, 44, 13 );
Paint.SetBrush( Palette.Error.WithAlpha( 0.18f ) );
Paint.DrawRect( badge, 6 );
Paint.SetPen( Palette.Error );
Paint.SetDefaultFont( 6, 700 );
Paint.DrawText( badge, "WRITES", TextFlag.Center );
}
// unavailable badge, e.g. "Not Installed"
if ( disabled )
{
Paint.SetDefaultFont( 6, 700 );
var badgeWidth = Paint.MeasureText( unavailable ).x + 12;
var badge = new Rect( badgeLeft, LocalRect.Top + 5, badgeWidth, 13 );
Paint.SetBrush( Palette.TextDim.WithAlpha( 0.15f ) );
Paint.DrawRect( badge, 6 );
Paint.SetPen( Palette.TextDim );
Paint.DrawText( badge, unavailable, TextFlag.Center );
}
// description
Paint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.5f ) : Palette.TextDim );
Paint.SetDefaultFont( 7 );
Paint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 20, LocalRect.Width - ToggleWidth - 20, 14 ),
_tool.Meta.Description, TextFlag.LeftCenter | TextFlag.SingleLine );
// enable/disable toggle (persisted per tool)
var off = UserDisabled;
Paint.SetPen( off ? Palette.TextDim : Theme.Green );
Paint.DrawIcon( new Rect( LocalRect.Right - ToggleWidth, LocalRect.Top, ToggleWidth - 8, LocalRect.Height ),
off ? "toggle_off" : "toggle_on", 22, TextFlag.Center );
}
}
using System.Text.Json;
using SboxMcp.Server;
using Xunit;
namespace SboxMcp.Tests;
public class ProtocolTests
{
[Fact]
public void Parse_request_with_id_and_params()
{
var req = JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"x"}}""" );
Assert.False( req.IsNotification );
Assert.Equal( 7, req.Id.Value.GetInt32() );
Assert.Equal( "tools/call", req.Method );
Assert.Equal( "x", req.Params.Value.GetProperty( "name" ).GetString() );
}
[Fact]
public void Parse_notification_has_no_id()
{
var req = JsonRpcRequest.Parse( """{"jsonrpc":"2.0","method":"notifications/initialized"}""" );
Assert.True( req.IsNotification );
Assert.Equal( "notifications/initialized", req.Method );
}
[Fact]
public void Parse_invalid_json_throws()
{
Assert.Throws<JsonRpcParseException>( () => JsonRpcRequest.Parse( "{nope" ) );
}
[Fact]
public void Parse_missing_method_throws()
{
Assert.Throws<JsonRpcParseException>( () => JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":1}""" ) );
}
[Fact]
public void Writer_result_emits_envelope()
{
var id = JsonDocument.Parse( "3" ).RootElement;
var json = JsonRpcWriter.Result( id, new { protocolVersion = "2025-06-18" } );
var doc = JsonDocument.Parse( json ).RootElement;
Assert.Equal( "2.0", doc.GetProperty( "jsonrpc" ).GetString() );
Assert.Equal( 3, doc.GetProperty( "id" ).GetInt32() );
Assert.Equal( "2025-06-18", doc.GetProperty( "result" ).GetProperty( "protocolVersion" ).GetString() );
}
[Fact]
public void Writer_error_emits_code_and_message()
{
var json = JsonRpcWriter.Error( null, JsonRpcError.MethodNotFound, "no such method" );
var doc = JsonDocument.Parse( json ).RootElement;
Assert.Equal( JsonValueKind.Null, JsonKind( doc, "id" ) );
Assert.Equal( -32601, doc.GetProperty( "error" ).GetProperty( "code" ).GetInt32() );
Assert.Equal( "no such method", doc.GetProperty( "error" ).GetProperty( "message" ).GetString() );
}
[Fact]
public void Records_serialize_camel_case()
{
var schema = JsonDocument.Parse( """{"type":"object"}""" ).RootElement;
var json = JsonRpcWriter.Result( null,
McpResults.ToolsList( new[] { new McpToolDescriptor( "a_tool", "does things", schema ) } ) );
var doc = JsonDocument.Parse( json ).RootElement;
var tool = doc.GetProperty( "result" ).GetProperty( "tools" )[0];
Assert.Equal( "a_tool", tool.GetProperty( "name" ).GetString() );
Assert.Equal( "does things", tool.GetProperty( "description" ).GetString() );
Assert.Equal( "object", tool.GetProperty( "inputSchema" ).GetProperty( "type" ).GetString() );
}
[Fact]
public void Version_negotiation()
{
// only 2025-06-18 is supported (older revisions require JSON-RPC batching)
Assert.Equal( "2025-06-18", McpVersion.Negotiate( "2025-06-18" ) );
Assert.Equal( "2025-06-18", McpVersion.Negotiate( "2025-03-26" ) );
Assert.Equal( "2025-06-18", McpVersion.Negotiate( null ) );
}
[Fact]
public void Null_id_is_rejected()
{
Assert.Throws<JsonRpcParseException>( () =>
JsonRpcRequest.Parse( """{"jsonrpc":"2.0","id":null,"method":"ping"}""" ) );
}
[Fact]
public void Text_content_shape()
{
var json = JsonRpcWriter.Result( null, McpResults.TextContent( "hello", isError: true ) );
var result = JsonDocument.Parse( json ).RootElement.GetProperty( "result" );
Assert.Equal( "text", result.GetProperty( "content" )[0].GetProperty( "type" ).GetString() );
Assert.Equal( "hello", result.GetProperty( "content" )[0].GetProperty( "text" ).GetString() );
Assert.True( result.GetProperty( "isError" ).GetBoolean() );
}
[Fact]
public void Image_content_shape()
{
var json = JsonRpcWriter.Result( null, McpResults.ImageContent( "QUJD", "a screenshot" ) );
var content = JsonDocument.Parse( json ).RootElement.GetProperty( "result" ).GetProperty( "content" );
Assert.Equal( "image", content[0].GetProperty( "type" ).GetString() );
Assert.Equal( "QUJD", content[0].GetProperty( "data" ).GetString() );
Assert.Equal( "image/png", content[0].GetProperty( "mimeType" ).GetString() );
Assert.Equal( "a screenshot", content[1].GetProperty( "text" ).GetString() );
}
static JsonValueKind JsonKind( JsonElement el, string prop ) =>
el.TryGetProperty( prop, out var v ) ? v.ValueKind : JsonValueKind.Undefined;
}
public class CharmRerollConsecutive : Charm
{
public const string ItemId = "charm_reroll_consecutive";
public static string Description() => $"See +1 perk choice for\neach consecutive reroll";
private int _consecutiveRerolls = 0;
public override void OnRerollBefore()
{
base.OnRerollBefore();
_consecutiveRerolls++;
Player.Modify( this, PlayerStat.NumPerkChoices, _consecutiveRerolls, ModifierType.Add );
}
public override void OnChoosePerk( TypeDescription type )
{
base.OnChoosePerk( type );
_consecutiveRerolls = 0;
Player.StopModifying( this, PlayerStat.NumPerkChoices );
}
public override void OnRunStart()
{
base.OnRunStart();
_consecutiveRerolls = 0;
}
}
using System;
using Sandbox;
public class FallingObject : Component
{
public Player Shooter { get; set; }
public TimeSince TimeSinceSpawn { get; set; }
public float Lifetime { get; set; }
public float FallProgress { get; set; }
protected float _startingHeight = 1024f;
public bool HasHitGround { get; set; }
protected override void OnStart()
{
base.OnStart();
TimeSinceSpawn = 0f;
if ( IsProxy )
return;
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( IsProxy )
return;
if ( TimeSinceSpawn > Lifetime )
{
if( !HasHitGround )
HitGround();
}
else
{
FallProgress = Utils.Map( TimeSinceSpawn, 0f, Lifetime, 0f, 1f );
WorldPosition = new Vector3( WorldPosition.x, WorldPosition.y, Utils.Map( FallProgress, 0f, 1f, _startingHeight, 0f ) );
}
}
public virtual void HitGround()
{
GameObject.Destroy();
}
}
using System;
using Sandbox;
public class Globals
{
}
public class GunActiveReload : Gun
{
public const string ItemId = "gun_active_reload";
public static string Description() => $"Start with {Perk.GetRichTextNameToken( typeof( PerkActiveReload ) )} {Perk.GetRichTextToken( nameof(PerkActiveReload) )}";
public override void OnRunStart()
{
base.OnRunStart();
Manager.Instance.Chat.AddLocalChatMessage( $"Got {Perk.GetRichTextNameToken( typeof( PerkActiveReload ) )} {Perk.GetRichTextToken( nameof( PerkActiveReload ) )}", from: "" );
Player.AddPerk( TypeLibrary.GetType( typeof( PerkActiveReload ) ) );
}
}
/// <summary>
/// Marker interface for anything that can register stat modifiers on a Player via Player.Modify.
/// Implemented by Perk, Gun, Charm, and Gem.
/// </summary>
public interface IStatModifier { }
using System;
using Sandbox;
public class LightningParticleEffect : Component
{
[Property] public ParticleEffect ParticleEffect { get; set; }
[Property] public ParticleSpriteRenderer ParticleRenderer { get; set; }
[Property] public ParticleRingEmitter RingEmitter { get; set; }
private const float CHARGE_TIME = 3f;
private const float DELAY = 0.5f;
private TimeSince _timeSinceReset;
protected override void OnStart()
{
base.OnStart();
_timeSinceReset = 0f;
}
[Rpc.Broadcast]
public void ResetEffect()
{
if ( !IsProxy ) // only for client effect
return;
if ( !ParticleRenderer.Enabled )
return;
_timeSinceReset = 0f;
RingEmitter.Rate = 500;
RingEmitter.Radius = 30f;
ParticleEffect.Alpha = 0f;
}
protected override void OnUpdate()
{
if ( !IsProxy ) // only for client effect
return;
if ( _timeSinceReset < DELAY )
return;
float progress = Utils.Map( _timeSinceReset, 0f, DELAY + CHARGE_TIME, 0f, 1f );
RingEmitter.Rate = 500;
RingEmitter.Radius = Utils.Map( progress, 0f, 1f, 30f, 0f, EasingType.SineIn );
ParticleEffect.Alpha = Utils.Map( progress, 0f, 1f, 0f, 1f, EasingType.ExpoIn );
}
[Rpc.Broadcast]
public void SetVisible( bool visible )
{
ParticleRenderer.Enabled = visible;
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, curse: true, alwaysOfferDebug: false )]
public class CurseObscureScreen : Perk
{
private const float TOTAL_TIME = 5f;
private const float FADE_IN_TIME = 0.2f;
private const float FADE_OUT_START = 4.5f;
private const float MAX_INTENSITY = 2f;
private TimeSince _timeSinceHurt;
private bool _isActive;
private float _fadeInStartIntensity;
static CurseObscureScreen()
{
Register<CurseObscureScreen>(
name: "Blindness",
imagePath: "textures/icons/vector/curse_obscure_screen.png",
description: level => $"Reduce vision for {TOTAL_TIME}s when hit"
);
}
public override void Start()
{
base.Start();
}
public override void Update( float dt )
{
base.Update( dt );
if ( !_isActive )
return;
var obscure = Manager.Instance.OverlayEffects[OverlayEffectsType.Obscure];
var vignette = obscure.GetComponent<Vignette>();
if ( _timeSinceHurt > TOTAL_TIME )
{
obscure.Enabled = false;
_isActive = false;
ShouldUpdate = false;
DisplayText = " ";
DisplayCooldown = 0f;
}
else
{
float intensity;
if ( _timeSinceHurt < FADE_IN_TIME )
intensity = Utils.Map( _timeSinceHurt, 0f, FADE_IN_TIME, _fadeInStartIntensity, MAX_INTENSITY, EasingType.SineOut );
else if ( _timeSinceHurt < FADE_OUT_START )
intensity = MAX_INTENSITY;
else
intensity = Utils.Map( _timeSinceHurt, FADE_OUT_START, TOTAL_TIME, MAX_INTENSITY, 0f, EasingType.SineIn );
vignette.Intensity = intensity;
DisplayText = $"{MathX.CeilToInt( TOTAL_TIME - _timeSinceHurt )}";
DisplayCooldown = Utils.Map( _timeSinceHurt, 0f, TOTAL_TIME, 1f, 0f );
}
}
public override void OnHit( float amount, DamageType damageType, bool isSelfInflicted, Vector2 dir, float force, Enemy enemySource, EnemyType enemyType, float previousHealth )
{
base.OnHit( amount, damageType, isSelfInflicted, dir, force, enemySource, enemyType, previousHealth );
if ( damageType == DamageType.Self )
return;
var obscure = Manager.Instance.OverlayEffects[OverlayEffectsType.Obscure];
var vignette = obscure.GetComponent<Vignette>();
_fadeInStartIntensity = _isActive ? vignette.Intensity : 0f;
obscure.Enabled = true;
_timeSinceHurt = 0f;
_isActive = true;
ShouldUpdate = true;
DisplayCooldown = 1f;
HighlightColor = new Color( 1f, 0f, 0.5f );
HighlightDuration = 0.4f;
HighlightOpacity = 2.5f;
Highlight();
IconScale = Game.Random.Float( 1.1f, 1.2f );
IconAngleOffset = Game.Random.Float( 8f, 12f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
}
void DisableEffect()
{
Manager.Instance.OverlayEffects[OverlayEffectsType.Obscure].Enabled = false;
_isActive = false;
ShouldUpdate = false;
}
public override void OnDie()
{
base.OnDie();
DisableEffect();
}
public override void Remove( bool restart = false )
{
base.Remove( restart );
DisableEffect();
}
}