Search the source of every open source package.
341 results
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 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!" );
}
}
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 Editor;
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 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;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;
namespace HammerTextureBrowser;
[Dock( "Editor", "OG Texture Browser", "texture" )]
public sealed class HammerTextureBrowserDock : Widget
{
private static readonly Regex QuotedMaterialProperty = new( "\"(?<key>[^\"]+)\"\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant );
private static readonly string[] PrimaryTextureKeys =
[
"TextureColor",
"TextureBaseColor",
"TextureAlbedo",
"AlbedoTexture",
"BaseTexture",
"BaseColorTexture",
"g_tColor",
"g_tBaseColor",
"g_tAlbedo"
];
private readonly HammerTextureList TextureList;
private readonly ComboBox SizeCombo;
private readonly LineEdit FilterEdit;
private readonly ComboBox KeywordsCombo;
private readonly Checkbox OnlyUsedTextures;
private readonly Label SelectedTextureLabel;
private readonly Label TextureSizeLabel;
private readonly Label CountLabel;
private readonly Button MarkButton;
private readonly Button ReplaceButton;
private readonly Button ReloadButton;
private readonly Button OpenSourceButton;
private List<Asset> AllMaterials = new();
private Asset SelectedMaterial;
private string KeywordFilter = string.Empty;
private int PreviewSize = 128;
public HammerTextureBrowserDock( Widget parent ) : base( parent )
{
MinimumSize = new( 420, 320 );
WindowTitle = "OG Texture Browser";
SetWindowIcon( "texture" );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
TextureList = Layout.Add( new HammerTextureList( this ), 1 );
TextureList.OnTextureSelected = SelectMaterial;
TextureList.OnTextureActivated = SelectMaterial;
TextureList.OnOpenInEditor = OpenMaterialSource;
TextureList.OnOpenInAssetBrowser = OpenInAssetBrowser;
var bottom = Layout.Add( new Widget( this ) );
bottom.Layout = Layout.Column();
bottom.Layout.Margin = 4;
bottom.Layout.Spacing = 3;
bottom.FixedHeight = Theme.RowHeight * 2 + 12;
bottom.OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( bottom.LocalRect );
return false;
};
var topRow = bottom.Layout.AddRow();
topRow.Spacing = 5;
var sizeBlock = topRow.Add( new Widget( this ) );
sizeBlock.FixedWidth = 138;
sizeBlock.MinimumWidth = 138;
sizeBlock.MaximumWidth = 138;
sizeBlock.Layout = Layout.Row();
sizeBlock.Layout.Margin = 0;
sizeBlock.Layout.Spacing = 5;
AddSmallLabel( sizeBlock.Layout, "Size:" );
SizeCombo = sizeBlock.Layout.Add( new ComboBox( sizeBlock ) );
SizeCombo.FixedWidth = 88;
SizeCombo.MinimumWidth = 88;
SizeCombo.MaximumWidth = 88;
SizeCombo.AddItem( "32x32", null, () => SetPreviewSize( 32 ) );
SizeCombo.AddItem( "64x64", null, () => SetPreviewSize( 64 ) );
SizeCombo.AddItem( "128x128", null, () => SetPreviewSize( 128 ) );
SizeCombo.AddItem( "256x256", null, () => SetPreviewSize( 256 ) );
SizeCombo.AddItem( "1:1", null, () => SetPreviewSize( 128 ) );
SizeCombo.CurrentIndex = 2;
StyleBottomInput( SizeCombo, 24 );
AddSmallLabel( topRow, "Filter:", 58 );
FilterEdit = topRow.Add( new LineEdit( this ) );
FilterEdit.FixedWidth = 176;
FilterEdit.MinimumWidth = 176;
FilterEdit.MaximumWidth = 176;
FilterEdit.PlaceholderText = "texture name";
FilterEdit.TextChanged += _ => RefreshList();
StyleBottomInput( FilterEdit );
SelectedTextureLabel = topRow.Add( new Label( this ) );
SelectedTextureLabel.MinimumWidth = 180;
SelectedTextureLabel.Alignment = TextFlag.LeftCenter;
SelectedTextureLabel.Text = "";
OpenSourceButton = topRow.Add( new Button( "Open Source" ) );
OpenSourceButton.FixedWidth = 96;
OpenSourceButton.Clicked = OpenSelectedSource;
var bottomRow = bottom.Layout.AddRow();
bottomRow.Spacing = 5;
OnlyUsedTextures = bottomRow.Add( new Checkbox( "Only used textures" ) );
OnlyUsedTextures.FixedWidth = 138;
OnlyUsedTextures.StateChanged += _ => RefreshList();
AddSmallLabel( bottomRow, "Keywords:", 58 );
KeywordsCombo = bottomRow.Add( new ComboBox( this ) );
KeywordsCombo.FixedWidth = 176;
KeywordsCombo.MinimumWidth = 176;
KeywordsCombo.MaximumWidth = 176;
KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
StyleBottomInput( KeywordsCombo, 24 );
MarkButton = bottomRow.Add( new Button( "Mark" ) );
MarkButton.FixedWidth = 76;
MarkButton.Clicked = MarkSelectedMaterial;
ReplaceButton = bottomRow.Add( new Button( "Replace" ) );
ReplaceButton.FixedWidth = 76;
ReplaceButton.Clicked = ReplaceSelectedMaterial;
ReloadButton = bottomRow.Add( new Button( "Reload" ) );
ReloadButton.FixedWidth = 76;
ReloadButton.Clicked = Reload;
TextureSizeLabel = bottomRow.Add( new Label( this ) );
TextureSizeLabel.MinimumWidth = 0;
TextureSizeLabel.MaximumWidth = 68;
TextureSizeLabel.Alignment = TextFlag.RightCenter;
bottomRow.AddStretchCell( 1 );
CountLabel = bottomRow.Add( new Label( this ) );
CountLabel.MinimumWidth = 0;
CountLabel.MaximumWidth = 96;
CountLabel.Alignment = TextFlag.RightCenter;
ReloadMaterials();
SetPreviewSize( PreviewSize );
RefreshList();
UpdateSelectedMaterialUi();
}
private void Reload()
{
ReloadMaterials();
RefreshList();
UpdateSelectedMaterialUi();
}
private static void AddSmallLabel( Layout row, string text, int fixedWidth = 0 )
{
var label = row.Add( new Label( text ) );
label.Alignment = fixedWidth > 0 ? TextFlag.RightCenter : TextFlag.LeftCenter;
label.FixedWidth = fixedWidth > 0 ? fixedWidth : text.Length * 6 + 4;
}
private static void StyleBottomInput( Widget widget, int fixedHeight = 22 )
{
widget.FixedHeight = fixedHeight;
widget.SetStyles(
"background-color: #2d3136; " +
"border: 1px solid #555c64; " +
"border-radius: 2px; " +
"color: #f2f2f2; " +
"padding-top: 0px; " +
"padding-bottom: 0px; " +
"padding-left: 5px; " +
"padding-right: 5px;" );
}
private void ReloadMaterials()
{
var menuPath = EditorUtility.Projects.GetAll()
.FirstOrDefault( x => x.Config.Ident == "menu" )
?.GetAssetsPath()
.NormalizeFilename( false );
AllMaterials = AssetSystem.All
.Where( IsBrowsableMaterial )
.Where( x => !IsMenuAsset( x, menuPath ) )
.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
.ToList();
RebuildKeywords();
}
private static bool IsBrowsableMaterial( Asset asset )
{
if ( asset is null )
return false;
if ( asset.AssetType != AssetType.Material )
return false;
if ( asset.AbsolutePath?.Contains( ".sbox/cloud/", StringComparison.OrdinalIgnoreCase ) ?? false )
return false;
return true;
}
private static bool IsMenuAsset( Asset asset, string menuPath )
{
if ( string.IsNullOrEmpty( menuPath ) )
return false;
return asset.AbsolutePath?.NormalizeFilename( false ).StartsWith( menuPath, StringComparison.OrdinalIgnoreCase ) ?? false;
}
private void RebuildKeywords()
{
var tags = AllMaterials
.SelectMany( x => x.Tags )
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
.ToList();
KeywordsCombo.Clear();
KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
foreach ( var tag in tags )
{
var tagValue = tag;
KeywordsCombo.AddItem( tagValue, null, () => SetKeywordFilter( tagValue ) );
}
KeywordsCombo.CurrentIndex = 0;
}
private void SetKeywordFilter( string tag )
{
KeywordFilter = tag ?? string.Empty;
RefreshList();
}
private void SetPreviewSize( int size )
{
PreviewSize = size;
TextureList.SetPreviewSize( PreviewSize );
RefreshList();
}
private void RefreshList()
{
if ( TextureList is null )
return;
IEnumerable<Asset> materials = AllMaterials;
var query = FilterEdit?.Text ?? string.Empty;
if ( !string.IsNullOrWhiteSpace( query ) )
{
var parts = query.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
materials = materials.Where( x => MatchesQuery( x, parts ) );
}
if ( !string.IsNullOrWhiteSpace( KeywordFilter ) )
{
materials = materials.Where( x => x.Tags.Any( tag => tag.Equals( KeywordFilter, StringComparison.OrdinalIgnoreCase ) ) );
}
if ( OnlyUsedTextures?.Value ?? false )
{
var used = HammerMaterialSelection.GetUsedMaterialKeys();
materials = materials.Where( x => used.Contains( x.RelativePath ) || used.Contains( x.AbsolutePath ) || used.Contains( TextureDisplayName( x ) ) );
}
var entries = materials
.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
.Select( x => new HammerTextureEntry( x ) )
.ToList();
TextureList.SetItems( entries );
if ( CountLabel is not null )
{
CountLabel.Text = $"{entries.Count:n0} textures";
CountLabel.Update();
}
if ( SelectedMaterial is not null && entries.All( x => x.Asset != SelectedMaterial ) )
TextureList.UnselectAll();
}
private static bool MatchesQuery( Asset asset, IEnumerable<string> parts )
{
var name = TextureDisplayName( asset );
var type = asset.AssetType?.FriendlyName ?? string.Empty;
IEnumerable<string> tags = asset.Tags;
foreach ( var part in parts )
{
var search = part;
var negated = false;
if ( search.StartsWith( "-" ) )
{
search = search[1..];
negated = true;
}
var matched =
name.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
type.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
tags.Any( x => x.Contains( search, StringComparison.OrdinalIgnoreCase ) );
if ( !negated && !matched )
return false;
if ( negated && matched )
return false;
}
return true;
}
private void SelectMaterial( Asset material )
{
if ( material?.AssetType != AssetType.Material )
return;
SelectedMaterial = material;
EditorUtility.InspectorObject = material;
HammerMaterialSelection.SetCurrentMaterial( material );
UpdateSelectedMaterialUi();
}
private void UpdateSelectedMaterialUi()
{
var hasMaterial = SelectedMaterial is not null;
SelectedTextureLabel.Text = hasMaterial ? TextureDisplayName( SelectedMaterial ) : "";
SelectedTextureLabel.ToolTip = SelectedMaterial?.RelativePath ?? string.Empty;
TextureSizeLabel.Text = GetTextureSizeText( SelectedMaterial );
TextureSizeLabel.ToolTip = SelectedMaterial?.AbsolutePath ?? string.Empty;
MarkButton.Enabled = hasMaterial;
ReplaceButton.Enabled = hasMaterial;
OpenSourceButton.Enabled = hasMaterial;
SelectedTextureLabel.Update();
TextureSizeLabel.Update();
MarkButton.Update();
ReplaceButton.Update();
OpenSourceButton.Update();
}
private static string GetTextureSizeText( Asset asset )
{
if ( asset is null )
return "";
var texturePath = GetPrimaryMaterialTexturePath( asset );
if ( string.IsNullOrWhiteSpace( texturePath ) )
return "";
try
{
var texture = Texture.Load( texturePath );
if ( texture is null || !texture.IsValid() || texture.Width <= 0 || texture.Height <= 0 )
return "";
return $"{texture.Width}x{texture.Height}";
}
catch
{
return "";
}
}
private static string GetPrimaryMaterialTexturePath( Asset asset )
{
if ( string.IsNullOrWhiteSpace( asset?.AbsolutePath ) || !File.Exists( asset.AbsolutePath ) )
return null;
try
{
var properties = QuotedMaterialProperty.Matches( File.ReadAllText( asset.AbsolutePath ) )
.Select( x => (Key: x.Groups["key"].Value, Value: NormalizeTexturePath( x.Groups["value"].Value )) )
.Where( x => IsTexturePath( x.Value ) )
.ToList();
foreach ( var key in PrimaryTextureKeys )
{
var match = properties.FirstOrDefault( x => x.Key.Equals( key, StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( match.Value ) )
return match.Value;
}
var colorMatch = properties.FirstOrDefault( x =>
(x.Key.Contains( "color", StringComparison.OrdinalIgnoreCase ) ||
x.Key.Contains( "albedo", StringComparison.OrdinalIgnoreCase ) ||
x.Key.Contains( "base", StringComparison.OrdinalIgnoreCase )) &&
!x.Key.Contains( "tint", StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( colorMatch.Value ) )
return colorMatch.Value;
return properties.FirstOrDefault().Value;
}
catch
{
return null;
}
}
private static string NormalizeTexturePath( string path )
{
return path?.Trim().Replace( '\\', '/' );
}
private static bool IsTexturePath( string path )
{
var extension = Path.GetExtension( path );
return extension.Equals( ".vtex", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tga", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".png", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".jpg", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".jpeg", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tif", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".tiff", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".psd", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".exr", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".hdr", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".bmp", StringComparison.OrdinalIgnoreCase ) ||
extension.Equals( ".webp", StringComparison.OrdinalIgnoreCase );
}
private void MarkSelectedMaterial()
{
if ( SelectedMaterial is null )
return;
HammerMaterialSelection.SelectFacesUsingMaterial( SelectedMaterial );
}
private void ReplaceSelectedMaterial()
{
if ( SelectedMaterial is null )
return;
HammerMaterialSelection.AssignAssetToSelection( SelectedMaterial );
}
private void OpenSelectedSource()
{
OpenMaterialSource( SelectedMaterial );
}
private void OpenMaterialSource( Asset asset )
{
if ( asset is null )
return;
if ( asset.CanOpenInEditor )
{
asset.OpenInEditor();
return;
}
EditorUtility.OpenFileFolder( asset.AbsolutePath );
}
private void OpenInAssetBrowser( Asset asset )
{
if ( asset is null )
return;
LocalAssetBrowser.OpenTo( asset, true );
}
internal static string TextureDisplayName( Asset asset )
{
var path = asset?.RelativePath ?? asset?.Name ?? "";
path = path.NormalizeFilename( false, false );
if ( path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
path = path["materials/".Length..];
var extension = Path.GetExtension( path );
if ( !string.IsNullOrEmpty( extension ) )
path = path[..^extension.Length];
return path;
}
}
internal sealed class HammerTextureList : ListView
{
private int PreviewSize = 128;
public Action<Asset> OnTextureSelected;
public Action<Asset> OnTextureActivated;
public Action<Asset> OnOpenInEditor;
public Action<Asset> OnOpenInAssetBrowser;
public HammerTextureList( Widget parent ) : base( parent )
{
MultiSelect = false;
FocusMode = FocusMode.TabOrClick;
Margin = 2;
ItemSpacing = 2;
ItemPaint = PaintTextureItem;
ItemSelected = item => SelectTexture( item, false );
ItemActivated = item => SelectTexture( item, true );
ItemDrag = StartTextureDrag;
ItemContextMenu = OpenTextureContextMenu;
ItemScrollEnter = item => (item as HammerTextureEntry)?.OnScrollEnter();
ItemScrollExit = item => (item as HammerTextureEntry)?.OnScrollExit();
OnPaintOverride = PaintBackground;
}
public void SetPreviewSize( int previewSize )
{
PreviewSize = previewSize;
ItemSize = new Vector2( PreviewSize + 4, PreviewSize + 18 );
Update();
}
public void SetItems( IEnumerable<HammerTextureEntry> entries )
{
base.SetItems( entries.Cast<object>() );
Update();
}
private bool PaintBackground()
{
Paint.ClearPen();
Paint.SetBrush( Color.Black );
Paint.DrawRect( LocalRect );
return false;
}
private void SelectTexture( object item, bool activated )
{
if ( item is not HammerTextureEntry entry )
return;
if ( activated )
OnTextureActivated?.Invoke( entry.Asset );
else
OnTextureSelected?.Invoke( entry.Asset );
}
private bool StartTextureDrag( object item )
{
if ( item is not HammerTextureEntry entry || entry.Asset is null )
return false;
SelectItem( entry );
SelectTexture( entry, false );
var drag = new Drag( this );
drag.Data.Object = entry.Asset;
drag.Data.Text = entry.Asset.RelativePath;
drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );
foreach ( var selected in SelectedItems.OfType<HammerTextureEntry>() )
{
if ( selected == entry || selected.Asset is null )
continue;
drag.Data.Text += "\n" + selected.Asset.RelativePath;
}
drag.Execute();
return true;
}
private void OpenTextureContextMenu( object item )
{
if ( item is not HammerTextureEntry entry )
return;
SelectItem( entry );
SelectTexture( entry, false );
var menu = new ContextMenu( this );
menu.AddOption( "Open in Editor", "edit", () => OnOpenInEditor?.Invoke( entry.Asset ) )
.Enabled = entry.Asset is not null;
menu.AddOption( "Open in Asset Browser", "search", () => OnOpenInAssetBrowser?.Invoke( entry.Asset ) )
.Enabled = entry.Asset is not null;
menu.OpenAtCursor( false );
}
private void PaintTextureItem( VirtualWidget item )
{
if ( item.Object is not HammerTextureEntry entry )
return;
var rect = item.Rect.Shrink( 1 );
var active = Paint.HasPressed;
var selected = Paint.HasSelected || Paint.HasPressed;
var hover = !selected && Paint.HasMouseOver;
Paint.ClearPen();
Paint.SetBrush( Color.Black );
Paint.DrawRect( rect );
if ( selected )
{
Paint.SetPen( Theme.Primary, 2 );
Paint.ClearBrush();
Paint.DrawRect( rect.Grow( -1 ) );
}
else if ( hover )
{
Paint.SetPen( Theme.ControlBackground.Lighten( 0.35f ), 1 );
Paint.ClearBrush();
Paint.DrawRect( rect.Grow( -1 ) );
}
var previewRect = rect;
previewRect.Width = PreviewSize;
previewRect.Height = PreviewSize;
previewRect.Left += MathF.Max( 0, (rect.Width - PreviewSize) * 0.5f );
entry.DrawPreview( previewRect, active );
var nameRect = previewRect;
nameRect.Top = previewRect.Bottom;
nameRect.Height = 12;
Paint.ClearPen();
Paint.SetBrush( selected ? Theme.Primary.Lighten( active ? -0.1f : 0.05f ) : new Color( 0.0f, 0.05f, 0.85f ) );
Paint.DrawRect( nameRect );
Paint.SetDefaultFont( 6 );
Paint.SetPen( Color.White );
var label = Paint.GetElidedText( entry.DisplayName, nameRect.Width - 4, ElideMode.Middle );
Paint.DrawText( nameRect.Shrink( 2, 0 ), label, TextFlag.LeftCenter | TextFlag.SingleLine );
}
}
internal sealed class HammerTextureEntry
{
public Asset Asset { get; }
public string DisplayName { get; }
public HammerTextureEntry( Asset asset )
{
Asset = asset;
DisplayName = HammerTextureBrowserDock.TextureDisplayName( asset );
}
public void OnScrollEnter()
{
if ( Asset is null || Asset.HasCachedThumbnail )
return;
EditorEvent.Register( this );
Asset.GetAssetThumb( true );
}
public void OnScrollExit()
{
if ( Asset is null )
return;
Asset.CancelThumbBuild();
EditorEvent.Unregister( this );
}
public void DrawPreview( Rect rect, bool active )
{
Paint.ClearPen();
Paint.SetBrush( active ? Color.Black.Lighten( 0.08f ) : Color.Black );
Paint.DrawRect( rect );
var thumb = Asset?.GetAssetThumb( true ) ?? AssetType.Material?.Icon128;
if ( thumb is null )
return;
var drawRect = rect;
var scale = MathF.Min( rect.Width / thumb.Width, rect.Height / thumb.Height );
if ( scale > 0 && float.IsFinite( scale ) )
{
drawRect.Width = MathF.Min( rect.Width, thumb.Width * scale );
drawRect.Height = MathF.Min( rect.Height, thumb.Height * scale );
drawRect.Left = rect.Left + (rect.Width - drawRect.Width) * 0.5f;
drawRect.Top = rect.Top + (rect.Height - drawRect.Height) * 0.5f;
}
Paint.BilinearFiltering = true;
Paint.Draw( drawRect, thumb );
Paint.BilinearFiltering = false;
}
}
internal static class HammerMaterialSelection
{
private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private static Type HammerType;
private static bool HasResolvedHammerType;
public static void SetCurrentMaterial( Asset asset ) => InvokeHammerAssetMethod( "SetCurrentMaterial", asset );
public static void SelectFacesUsingMaterial( Asset asset ) => InvokeHammerAssetMethod( "SelectFacesUsingMaterial", asset );
public static void AssignAssetToSelection( Asset asset ) => InvokeHammerAssetMethod( "AssignAssetToSelection", asset );
public static HashSet<string> GetUsedMaterialKeys()
{
var keys = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
try
{
var activeMap = GetHammerType()?.GetProperty( "ActiveMap", StaticFlags )?.GetValue( null );
if ( activeMap is null )
return keys;
var world = activeMap.GetType().GetProperty( "World", InstanceFlags )?.GetValue( activeMap );
if ( world is null )
return keys;
CollectUsedMaterialKeys( world, keys );
}
catch
{
// Hammer throws when no map is open; in that case "Only used textures" should simply show none.
return keys;
}
return keys;
}
private static void CollectUsedMaterialKeys( object node, HashSet<string> keys )
{
if ( node is null )
return;
var materialMethod = node.GetType().GetMethod( "GetFaceMaterialAssets", InstanceFlags );
if ( materialMethod is not null && materialMethod.Invoke( node, null ) is IEnumerable materials )
{
foreach ( var item in materials )
{
if ( item is not Asset asset )
continue;
keys.Add( asset.RelativePath );
keys.Add( asset.AbsolutePath );
keys.Add( HammerTextureBrowserDock.TextureDisplayName( asset ) );
}
}
var children = node.GetType().GetProperty( "Children", InstanceFlags )?.GetValue( node ) as IEnumerable;
if ( children is null )
return;
foreach ( var child in children )
{
CollectUsedMaterialKeys( child, keys );
}
}
private static void InvokeHammerAssetMethod( string methodName, Asset asset )
{
if ( asset is null )
return;
var method = GetHammerType()?.GetMethod( methodName, StaticFlags, binder: null, types: new[] { typeof( Asset ) }, modifiers: null );
if ( method is null )
return;
try
{
method.Invoke( null, new object[] { asset } );
}
catch
{
// This dock still works as a browser if Hammer is not the active editor surface.
}
}
private static Type GetHammerType()
{
if ( HasResolvedHammerType )
return HammerType;
HasResolvedHammerType = true;
foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
{
HammerType = assembly.GetType( "Editor.MapEditor.Hammer" );
if ( HammerType is not null )
break;
}
return HammerType;
}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// ═══════════════════════════════════════════════════════════════════════════
// debug_draw_* / debug_clear — visualize debug primitives in the scene.
//
// Ported from the Claude Bridge for Unity's debug_draw_* family. s&box has no
// bridge debug-viz; this fills the gap so a raycast hit / physics_overlap
// volume / trigger_zone bounds / NPC sight cone / patrol path can be SEEN
// (and screenshot-verified) instead of reasoned about blind.
//
// ONE component, dual render path:
// • EDIT scene → Gizmo.Draw.* inside ClaudeDebugDraw.DrawGizmos()
// • PLAY scene → Game.ActiveScene.DebugOverlay.* re-emitted each OnUpdate()
// A single NotSaved holder GameObject ("__ClaudeDebugDraw") stores the prim
// list; the draw handlers append, debug_clear destroys it.
//
// APIs reflected live on this SDK (describe_type, 2026-06-18):
// Gizmo.Draw: Line(a,b) · Arrow(from,to,len,width) · LineBBox(bbox) ·
// LineSphere(Sphere,rings) · Color/LineThickness/IgnoreDepth
// Scene.DebugOverlay (DebugOverlaySystem):
// Line(from,to,color,dur,tx,overlay) · Box(BBox,color,dur,tx,overlay) ·
// Sphere(Sphere,color,dur,tx,overlay)
//
// Must work WHILE playing → these are NOT added to _sceneMutatingCommands.
// ═══════════════════════════════════════════════════════════════════════════
public enum DebugDrawKind { Line, Ray, Box, Sphere }
public sealed class DebugDrawPrim
{
public DebugDrawKind Kind;
public Vector3 A; // line/ray start · box/sphere center
public Vector3 B; // line/ray end
public Vector3 Size; // box full extents
public float Radius; // sphere
public Color Color = Color.Yellow;
public float Thickness = 2f;
}
/// <summary>
/// Holds bridge-issued debug primitives and renders them in both the editor
/// (DrawGizmos) and play mode (DebugOverlay). One per scene, NotSaved.
/// </summary>
public sealed class ClaudeDebugDraw : Component
{
public List<DebugDrawPrim> Prims { get; set; } = new();
protected override void DrawGizmos()
{
if ( Prims == null ) return;
foreach ( var p in Prims )
{
Gizmo.Draw.Color = p.Color;
Gizmo.Draw.LineThickness = p.Thickness;
Gizmo.Draw.IgnoreDepth = true;
switch ( p.Kind )
{
case DebugDrawKind.Line: Gizmo.Draw.Line( p.A, p.B ); break;
case DebugDrawKind.Ray: Gizmo.Draw.Arrow( p.A, p.B, 8f, 3f ); break;
case DebugDrawKind.Box: Gizmo.Draw.LineBBox( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ) ); break;
case DebugDrawKind.Sphere: Gizmo.Draw.LineSphere( new Sphere( p.A, p.Radius ), 16 ); break;
}
}
}
protected override void OnUpdate()
{
if ( !Game.IsPlaying || Prims == null ) return;
var ov = Scene?.DebugOverlay;
if ( ov == null ) return;
const float dur = 0.1f; // refreshed every frame while in the list
var tx = global::Transform.Zero; // identity → world-space coords (Transform is global-namespace, not Sandbox.*)
foreach ( var p in Prims )
{
switch ( p.Kind )
{
case DebugDrawKind.Line:
case DebugDrawKind.Ray:
ov.Line( p.A, p.B, p.Color, dur, tx, true );
break;
case DebugDrawKind.Box:
ov.Box( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ), p.Color, dur, tx, true );
break;
case DebugDrawKind.Sphere:
ov.Sphere( new Sphere( p.A, p.Radius ), p.Color, dur, tx, true );
break;
}
}
}
}
internal static class DebugDrawHelpers
{
static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
// ponytail: one global holder per session, recreated if invalidated by a
// scene change / hotload. Debug viz is inherently global, so a single
// instance is correct — no per-call scene scan needed.
static ClaudeDebugDraw _holder;
public static Scene CurrentScene()
=> Game.IsPlaying ? Game.ActiveScene : SceneEditorSession.Active?.Scene;
public static ClaudeDebugDraw EnsureHolder()
{
var scene = CurrentScene();
if ( scene == null ) return null;
if ( _holder.IsValid() && _holder.Scene == scene ) return _holder;
var go = scene.CreateObject( true );
go.Name = "__ClaudeDebugDraw";
go.Flags = GameObjectFlags.NotSaved;
_holder = go.AddComponent<ClaudeDebugDraw>();
return _holder;
}
public static int ClearHolder()
{
int n = 0;
// cached holder — reliable for the common same-scene case
if ( _holder.IsValid() )
{
n += _holder.Prims?.Count ?? 0;
_holder.GameObject?.Destroy();
}
// plus any holders orphaned by an edit↔play scene switch (the static ref
// only tracks the most recent scene's holder)
var scene = CurrentScene();
if ( scene != null )
{
foreach ( var c in scene.GetAllComponents<ClaudeDebugDraw>().ToList() )
{
if ( c == _holder ) continue;
n += c.Prims?.Count ?? 0;
c.GameObject?.Destroy();
}
}
_holder = null;
return n;
}
public static bool TryVec( JsonElement p, string key, out Vector3 v )
{
v = Vector3.Zero;
if ( !p.TryGetProperty( key, out var e ) ) return false;
switch ( e.ValueKind )
{
case JsonValueKind.String:
var s = e.GetString().Split( ',' );
if ( s.Length < 3 ) return false;
v = new Vector3( F( s[0] ), F( s[1] ), F( s[2] ) );
return true;
case JsonValueKind.Array:
if ( e.GetArrayLength() < 3 ) return false;
v = new Vector3( (float)e[0].GetDouble(), (float)e[1].GetDouble(), (float)e[2].GetDouble() );
return true;
case JsonValueKind.Object:
v = new Vector3(
(float)e.GetProperty( "x" ).GetDouble(),
(float)e.GetProperty( "y" ).GetDouble(),
(float)e.GetProperty( "z" ).GetDouble() );
return true;
default:
return false;
}
}
public static Color Col( JsonElement p, string key, Color def )
{
if ( !p.TryGetProperty( key, out var e ) || e.ValueKind != JsonValueKind.String ) return def;
var s = e.GetString().Split( ',' );
if ( s.Length < 3 ) return def;
float a = s.Length >= 4 ? F( s[3] ) : 1f;
return new Color( F( s[0] ), F( s[1] ), F( s[2] ), a );
}
public static float Flt( JsonElement p, string key, float def )
=> p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.Number ? (float)e.GetDouble() : def;
static float F( string s ) => float.Parse( s.Trim(), Inv );
}
// ── handlers ────────────────────────────────────────────────────────────────
public class DebugDrawLineHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "from", out var a ) || !DebugDrawHelpers.TryVec( p, "to", out var b ) )
return Task.FromResult<object>( new { error = "from and to are required (\"x,y,z\")" } );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Line, A = a, B = b,
Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "line", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_line failed: {ex.Message}" } ); }
}
}
public class DebugDrawRayHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "origin", out var o ) || !DebugDrawHelpers.TryVec( p, "direction", out var d ) )
return Task.FromResult<object>( new { error = "origin and direction are required (\"x,y,z\")" } );
float len = DebugDrawHelpers.Flt( p, "length", 64f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Ray, A = o, B = o + d.Normal * len,
Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "ray", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_ray failed: {ex.Message}" } ); }
}
}
public class DebugDrawBoxHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
Vector3 size = DebugDrawHelpers.TryVec( p, "size", out var sz ) ? sz : new Vector3( 32f, 32f, 32f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Box, A = c, Size = size,
Color = DebugDrawHelpers.Col( p, "color", Color.Green ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "box", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_box failed: {ex.Message}" } ); }
}
}
public class DebugDrawSphereHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
float r = DebugDrawHelpers.Flt( p, "radius", 32f );
var h = DebugDrawHelpers.EnsureHolder();
if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
h.Prims.Add( new DebugDrawPrim
{
Kind = DebugDrawKind.Sphere, A = c, Radius = r,
Color = DebugDrawHelpers.Col( p, "color", Color.Red ),
Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
} );
return Task.FromResult<object>( new { drawn = "sphere", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_sphere failed: {ex.Message}" } ); }
}
}
public class DebugClearHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
int removed = DebugDrawHelpers.ClearHolder();
return Task.FromResult<object>( new { cleared = true, removed } );
}
catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_clear failed: {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}" } );
}
}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Document;
public sealed record CheckboxPayload : Payload
{
[JsonIgnore]
public override ControlType Kind => ControlType.Checkbox;
// Checkbox label text. Overrides Payload.Content (neutral default "").
public override string Content { get; init; } = "";
public override Length CheckboxSize { get; init; } = Length.Px( 16 );
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Inspector;
[CustomEditor( typeof( Edges ) )]
public sealed class EdgesControlWidget : ControlWidget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public override bool SupportsMultiEdit => true;
private readonly EdgesProxy _proxy;
private readonly SerializedObject _proxySerialized;
// Synchronous change events on both sides; without this guard SetValue would loop.
private bool _syncing;
private sealed class EdgesProxy
{
public Length Top { get; set; } = Length.Px( 0 );
public Length Right { get; set; } = Length.Px( 0 );
public Length Bottom { get; set; } = Length.Px( 0 );
public Length Left { get; set; } = Length.Px( 0 );
}
public EdgesControlWidget( SerializedProperty property ) : base( property )
{
Log.Info( $"{LogPrefix} EdgesControlWidget ctor for {property.Name}" );
Layout = Layout.Column();
Layout.Spacing = 2;
_proxy = new EdgesProxy();
_proxySerialized = EditorTypeLibrary.GetSerializedObject( _proxy );
var topRow = Layout.Add( Layout.Row() );
topRow.Spacing = 2;
AddSide( topRow, nameof( EdgesProxy.Top ), "border_top" );
AddSide( topRow, nameof( EdgesProxy.Right ), "border_right" );
var bottomRow = Layout.Add( Layout.Row() );
bottomRow.Spacing = 2;
AddSide( bottomRow, nameof( EdgesProxy.Bottom ), "border_bottom" );
AddSide( bottomRow, nameof( EdgesProxy.Left ), "border_left" );
_proxySerialized.OnPropertyChanged += OnProxyChanged;
SyncFromProperty();
}
private void AddSide( Layout row, string propName, string icon )
{
var prop = _proxySerialized.GetProperty( propName );
var lengthWidget = new LengthControlWidget( prop, icon );
row.Add( lengthWidget, 1 );
}
protected override void PaintControl()
{
// nothing
}
private void SyncFromProperty()
{
if ( _syncing ) return;
_syncing = true;
try
{
var e = SerializedProperty.GetValue<Edges>( Edges.Zero );
_proxySerialized.GetProperty( nameof( EdgesProxy.Top ) ).SetValue( e.Top );
_proxySerialized.GetProperty( nameof( EdgesProxy.Right ) ).SetValue( e.Right );
_proxySerialized.GetProperty( nameof( EdgesProxy.Bottom ) ).SetValue( e.Bottom );
_proxySerialized.GetProperty( nameof( EdgesProxy.Left ) ).SetValue( e.Left );
}
finally
{
_syncing = false;
}
}
private void OnProxyChanged( SerializedProperty property )
{
if ( _syncing ) return;
if ( ReadOnly || !SerializedProperty.IsEditable )
return;
_syncing = true;
try
{
var newValue = new Edges( _proxy.Top, _proxy.Right, _proxy.Bottom, _proxy.Left );
Log.Info( $"{LogPrefix} EdgesControlWidget OnProxyChanged {newValue}" );
PropertyStartEdit();
SerializedProperty.SetValue( newValue );
SignalValuesChanged();
PropertyFinishEdit();
}
finally
{
_syncing = false;
}
}
protected override void OnValueChanged()
{
base.OnValueChanged();
SyncFromProperty();
}
}
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Templates;
using Sandbox;
namespace Grains.RazorDesigner.Palette;
public class PalettePanel : Widget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const string CookiePrefix = "razordesigner.palette.";
// Click-to-add target. Window decides where the new record goes (typically active selection or root).
public event Action<ControlType> TypeAddRequested;
// Click-to-add a saved template. Window decides where to insert.
public event Action<PaletteTemplate> TemplateAddRequested;
private readonly PaletteTemplateStore _templateStore = new();
private CollapsibleSection _templatesSection;
private WrapPanel _templatesWrap;
public PaletteTemplateStore TemplateStore => _templateStore;
public PalettePanel( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
MinimumWidth = 180;
VerticalSizeMode = SizeMode.CanGrow;
var byCategory = new Dictionary<ControlCategory, List<ControlType>>();
foreach ( ControlType type in Enum.GetValues( typeof( ControlType ) ) )
{
var cat = ControlDefaults.For( type ).Category;
if ( !byCategory.TryGetValue( cat, out var list ) )
{
list = new List<ControlType>();
byCategory[cat] = list;
}
list.Add( type );
}
// Templates section (top of palette). Hidden when store is empty; rebuilt on Changed.
_templatesSection = new CollapsibleSection( this, "Templates", "bookmark" );
_templatesWrap = new WrapPanel( null )
{
MinItemWidth = 92,
ItemHeight = (int)( Theme.RowHeight + 4 ),
HSpacing = 4,
VSpacing = 4,
PaddingLeft = 4,
PaddingTop = 4,
PaddingRight = 14,
PaddingBottom = 4,
};
_templatesSection.BodyLayout.Add( _templatesWrap );
var templatesCookie = $"{CookiePrefix}templates.expanded";
_templatesSection.Expanded = EditorCookie.Get<bool>( templatesCookie, true );
_templatesSection.ExpandedChanged += expanded =>
{
EditorCookie.Set( templatesCookie, expanded );
Log.Info( $"{LogPrefix} Palette Templates {(expanded ? "expanded" : "collapsed")}" );
};
Layout.Add( _templatesSection );
_templateStore.Changed += RebuildTemplatesSection;
_templateStore.Scan(); // initial fill (also fires Changed and rebuilds the section)
foreach ( ControlCategory cat in Enum.GetValues( typeof( ControlCategory ) ) )
{
if ( !byCategory.TryGetValue( cat, out var list ) ) continue;
var section = new CollapsibleSection(
this,
ControlDefaults.CategoryDisplayName( cat ),
CategoryIcon( cat ) );
var wrap = new WrapPanel( null )
{
MinItemWidth = 92,
ItemHeight = (int)( Theme.RowHeight + 4 ),
HSpacing = 4,
VSpacing = 4,
PaddingLeft = 4,
PaddingTop = 4,
PaddingRight = 14, // clear the ScrollArea's vertical scrollbar
PaddingBottom = 4,
};
section.BodyLayout.Add( wrap );
foreach ( var t in list )
new PaletteTypeButton( wrap, this, t );
var cookieKey = $"{CookiePrefix}{cat}.expanded";
section.Expanded = EditorCookie.Get<bool>( cookieKey, DefaultExpanded( cat ) );
section.ExpandedChanged += expanded =>
{
EditorCookie.Set( cookieKey, expanded );
Log.Info( $"{LogPrefix} Palette category {cat} {(expanded ? "expanded" : "collapsed")}" );
};
Layout.Add( section );
}
Layout.AddStretchCell();
Log.Info( $"{LogPrefix} PalettePanel ctor (icon grid, {byCategory.Count} categories)" );
}
internal void NotifyTypeClicked( ControlType type )
{
Log.Info( $"{LogPrefix} PalettePanel.NotifyTypeClicked: {type}" );
TypeAddRequested?.Invoke( type );
}
internal void NotifyTemplateClicked( PaletteTemplate template )
{
Log.Info( $"{LogPrefix} PalettePanel.NotifyTemplateClicked: \"{template.Name}\"" );
TemplateAddRequested?.Invoke( template );
}
internal void RequestTemplateDelete( PaletteTemplate template )
{
var dialog = new Editor.Dialog( this );
dialog.Window.WindowTitle = "Delete template";
dialog.Window.SetWindowIcon( "delete" );
dialog.Window.SetModal( true, true );
dialog.Window.MinimumWidth = 320;
dialog.Layout = Layout.Column();
dialog.Layout.Margin = 16;
dialog.Layout.Spacing = 10;
dialog.Layout.Add( new Editor.Label( dialog )
{
Text = $"Delete template \"{template.Name}\"?",
} );
var hint = new Editor.Label( dialog )
{
Text = "Already-instantiated copies in open documents are unaffected.",
};
hint.SetStyles( "color: #888; font-size: 11px;" );
dialog.Layout.Add( hint );
var buttonRow = dialog.Layout.Add( Layout.Row() );
buttonRow.Spacing = 6;
buttonRow.AddStretchCell();
var cancel = new Editor.Button( dialog ) { Text = "Cancel", MinimumWidth = 72 };
cancel.MouseLeftPress += () => dialog.Close();
buttonRow.Add( cancel );
var del = new Editor.Button( dialog ) { Text = "Delete", MinimumWidth = 72 };
del.SetStyles( "color: #e07070;" );
del.MouseLeftPress += () =>
{
Log.Info( $"{LogPrefix} Palette delete confirmed: \"{template.Name}\"" );
_templateStore.Delete( template );
dialog.Close();
};
buttonRow.Add( del );
dialog.Window.AdjustSize();
dialog.Show();
}
private void RebuildTemplatesSection()
{
var templates = _templateStore.All;
// Hide the entire section (header + body) when there are no templates.
_templatesSection.Visible = templates.Count > 0;
using ( Editor.SuspendUpdates.For( _templatesWrap ) )
{
_templatesWrap.DestroyChildren();
foreach ( var t in templates )
new PaletteTemplateButton( _templatesWrap, this, t );
}
_templatesWrap.Relayout();
_templatesWrap.UpdateGeometry();
_templatesSection.UpdateGeometry();
UpdateGeometry();
Log.Info( $"{LogPrefix} PalettePanel.RebuildTemplatesSection: {templates.Count} tile(s), section.Visible={_templatesSection.Visible}" );
}
private static bool DefaultExpanded( ControlCategory cat ) =>
cat is ControlCategory.Layout or ControlCategory.Display or ControlCategory.Input;
private static string CategoryIcon( ControlCategory cat ) => cat switch
{
ControlCategory.Layout => "view_quilt",
ControlCategory.Display => "visibility",
ControlCategory.Input => "edit",
ControlCategory.Form => "list_alt",
_ => "category",
};
private sealed class PaletteTypeButton : Widget
{
private readonly PalettePanel _owner;
private readonly ControlType _type;
// InspectorIcon comes from the contract (engine-fidelity); drag defaults from ControlDefaults.
private readonly string _icon;
public PaletteTypeButton( Widget parent, PalettePanel owner, ControlType type ) : base( parent )
{
_owner = owner;
_type = type;
_icon = ContractScanner.Table.Get( type ).InspectorIcon;
ToolTip = type.ToString();
Cursor = CursorShape.Finger;
MouseTracking = true;
IsDraggable = true;
}
protected override void OnPaint()
{
var rect = LocalRect.Shrink( 1 );
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var tint = ControlPresentation.IconTint( _type );
var fillAlpha = Paint.HasMouseOver ? 0.35f : 0.15f;
var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
Paint.SetPen( tint.WithAlpha( borderAlpha ) );
Paint.DrawRect( rect, 3 );
var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
Paint.DrawIcon( iconRect, _icon, 16, TextFlag.Center );
var textRect = rect;
textRect.Left = iconRect.Right + 2;
textRect.Right -= 4;
Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
Paint.SetDefaultFont();
Paint.DrawText( textRect, _type.ToString(), TextFlag.LeftCenter );
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
_owner.NotifyTypeClicked( _type );
}
protected override void OnDragStart()
{
base.OnDragStart();
var drag = new Drag( this );
drag.Data.Object = _type;
drag.Data.Text = $"palette:{_type}";
drag.Execute();
Log.Info( $"{LogPrefix} PaletteTypeButton.OnDragStart: {_type}" );
}
}
private sealed class PaletteTemplateButton : Widget
{
private readonly PalettePanel _owner;
private readonly PaletteTemplate _template;
public PaletteTemplateButton( Widget parent, PalettePanel owner, PaletteTemplate template ) : base( parent )
{
_owner = owner;
_template = template;
ToolTip = template.Name;
Cursor = CursorShape.Finger;
MouseTracking = true;
IsDraggable = true;
}
protected override void OnPaint()
{
var rect = LocalRect.Shrink( 1 );
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var tint = ControlPresentation.TemplateTint;
var fillAlpha = Paint.HasMouseOver ? 0.18f : 0.08f;
var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
Paint.SetPen( tint.WithAlpha( borderAlpha ) );
Paint.DrawRect( rect, 3 );
var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
var icon = string.IsNullOrEmpty( _template.IconName ) ? "bookmark" : _template.IconName;
Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
Paint.DrawIcon( iconRect, icon, 16, TextFlag.Center );
var textRect = rect;
textRect.Left = iconRect.Right + 2;
textRect.Right -= 4;
Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
Paint.SetDefaultFont();
Paint.DrawText( textRect, _template.Name, TextFlag.LeftCenter );
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
_owner.NotifyTemplateClicked( _template );
}
protected override void OnContextMenu( ContextMenuEvent e )
{
base.OnContextMenu( e );
var menu = new Menu( this );
menu.AddOption( "Delete…", "delete", () => _owner.RequestTemplateDelete( _template ) );
menu.OpenAtCursor();
e.Accepted = true;
}
protected override void OnDragStart()
{
base.OnDragStart();
var drag = new Drag( this );
drag.Data.Object = _template;
drag.Data.Text = $"template:{_template.Name}";
drag.Execute();
Log.Info( $"{LogPrefix} PaletteTemplateButton.OnDragStart: \"{_template.Name}\"" );
}
}
}
namespace Grains.RazorDesigner.Projection.CSharp;
public abstract record CSharpOp;
// File-level scaffold
public sealed record HeaderBanner( string ClassName, string Namespace ) : CSharpOp;
public sealed record UsingDirective( string Namespace ) : CSharpOp;
public sealed record NamespaceOpen( string Namespace ) : CSharpOp;
public sealed record ClassOpen( string ClassName, string BaseClass ) : CSharpOp;
public sealed record ClassClose() : CSharpOp;
public sealed record FieldDecl(
string Visibility, string Type, string Name, string InitialExpr,
bool IsParameter, bool IsProperty = false ) : CSharpOp;
public sealed record MethodOpen(
string Visibility, bool IsOverride, bool IsAsync,
string ReturnType, string Name, string ParameterList ) : CSharpOp;
public sealed record MethodClose() : CSharpOp;
// Body-level
public sealed record Statement( string Code ) : CSharpOp; // single `;`-terminated line
public sealed record BlockOpen( string Header ) : CSharpOp; // e.g. `if ( <cond> )` — applier writes "<header> {\n" and indents
public sealed record BlockClose() : CSharpOp;
public sealed record BlankLine() : CSharpOp;
public sealed record Comment( string Text ) : CSharpOp; // `// <text>`
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.CSharp.Projectors;
namespace Grains.RazorDesigner.Projection.CSharp;
public static class CSharpProjector
{
public static CSharpResult Project(
IReadOnlyWiring wiring,
bool documentHasAnyBindings )
{
if ( wiring.Symbols.Count == 0 && !documentHasAnyBindings )
return new CSharpResult( System.Array.Empty<CSharpOp>(), null );
var ctx = new CSharpProjectorContext( wiring );
var ops = new List<CSharpOp>( 64 );
ops.Add( new HeaderBanner( wiring.ClassName, wiring.Namespace ) );
ops.Add( new UsingDirective( "Sandbox" ) );
ops.Add( new UsingDirective( "Sandbox.UI" ) );
foreach ( var u in wiring.Usings )
ops.Add( new UsingDirective( u ) );
ops.Add( new NamespaceOpen( wiring.Namespace ) );
ops.Add( new ClassOpen( wiring.ClassName, wiring.BaseClass ) );
// Step 3: body. SymbolProjector handles grouping + sorting + per-kind dispatch.
SymbolProjector.EmitAll( wiring, ops, ctx );
ops.Add( new ClassClose() );
var source = CSharpApplier.Apply( ops ).Replace( "\r\n", "\n" );
return new CSharpResult( ops, source );
}
}
using System.Collections.Generic;
using Grains.RazorDesigner.Wiring;
namespace Grains.RazorDesigner.Projection.CSharp.Projectors;
public static class ParameterSymbolProjector
{
public static void Emit( ParameterSymbol s, List<CSharpOp> ops, CSharpProjectorContext ctx )
{
var initial = s.Initial is null ? "default" : ExpressionEmitter.Emit( s.Initial, ctx );
ops.Add( new FieldDecl(
Visibility: "public", Type: s.Type, Name: s.Name,
InitialExpr: initial, IsParameter: true ) );
}
}
namespace Grains.RazorDesigner.Projection;
public static class Escape
{
public static string Html( string s )
{
if ( string.IsNullOrEmpty( s ) ) return "";
return s
.Replace( "&", "&" )
.Replace( "<", "<" )
.Replace( ">", ">" )
.Replace( "\"", """ );
}
}
using System;
using System.Collections.Generic;
using Sandbox; // Color
namespace Grains.RazorDesigner.Projection;
public interface IReadOnlyStateRule
{
Document.PseudoKind State { get; }
Document.NthChildMode NthChildMode { get; }
int NthChildArg { get; }
IAppearance Delta { get; }
public static int CompareCanonical( IReadOnlyStateRule a, IReadOnlyStateRule b )
{
int c = ((int)a.State).CompareTo( (int)b.State );
if ( c != 0 ) return c;
c = ((int)a.NthChildMode).CompareTo( (int)b.NthChildMode );
if ( c != 0 ) return c;
return a.NthChildArg.CompareTo( b.NthChildArg );
}
}
public interface IReadOnlyNode
{
Guid Id { get; }
string Kind { get; } // == ControlType.ToString()
string ClassName { get; }
IAppearance Appearance { get; }
IPayload Payload { get; }
IReadOnlyList<IReadOnlyNode> Children { get; } // non-slot children
IReadOnlyDictionary<string, IReadOnlyList<IReadOnlyNode>> Slots { get; } // slot-name -> slot children (only SplitContainer populates)
IReadOnlyList<IReadOnlyStateRule> StateRules { get; } // per-state style deltas; canonical order not guaranteed here (the Applier sorts)
}
public interface IAppearance
{
// Layout
Document.Length Width { get; }
Document.Length Height { get; }
// Flex container
Document.FlexDirection Direction { get; }
Document.JustifyContent Justify { get; }
Document.AlignItems Align { get; }
float Gap { get; }
Document.Edges Padding { get; }
Document.FlexWrap Wrap { get; }
// Positioning (grd-7t2z)
Document.PositionKind Position { get; }
Document.Length Top { get; }
Document.Length Left { get; }
Document.Length Right { get; }
Document.Length Bottom { get; }
// Flex self
float FlexGrow { get; }
float FlexShrink { get; }
Document.Length FlexBasis { get; }
Document.AlignSelfKind AlignSelf { get; }
// Typography + OverrideTypography
bool OverrideTypography { get; }
string FontFamily { get; }
Document.Length FontSize { get; }
int FontWeight { get; }
Color Color { get; }
Document.TextAlignment TextAlign { get; }
bool FontStyleItalic { get; }
Document.TextTransformKind TextTransform { get; }
Document.Length LetterSpacing { get; }
Document.Length LineHeight { get; }
// Background + OverrideBackground
bool OverrideBackground { get; }
Color BackgroundColor { get; }
string BackgroundImage { get; }
string BackgroundSize { get; }
string BackgroundPosition { get; }
string BackgroundRepeat { get; }
// Border + OverrideBorder
bool OverrideBorder { get; }
Document.Length BorderRadius { get; }
Color BorderColor { get; }
Document.Length BorderWidth { get; }
// Effects + OverrideEffects
bool OverrideEffects { get; }
Document.Length BoxShadowX { get; }
Document.Length BoxShadowY { get; }
Document.Length BoxShadowBlur { get; }
Color BoxShadowColor { get; }
bool BoxShadowInset { get; }
float Opacity { get; }
// Constraints + OverrideConstraints
bool OverrideConstraints { get; }
Document.Edges Margin { get; }
Document.Length MinWidth { get; }
Document.Length MaxWidth { get; }
Document.Length MinHeight { get; }
Document.Length MaxHeight { get; }
// Interaction + OverrideInteraction
bool OverrideInteraction { get; }
Document.CursorKind Cursor { get; }
Document.OverflowKind Overflow { get; }
int ZIndex { get; }
bool PointerEvents { get; }
}
public interface IPayload
{
string Content { get; } // Label/Button text; Checkbox label
string Placeholder { get; } // TextEntry
string Source { get; } // Image src
string IconName { get; } // IconPanel glyph
Document.Length CheckboxSize { get; } // Checkbox box size
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;
namespace Grains.RazorDesigner.Projection.Projectors;
[Projector( "Button" )]
public sealed class ButtonProjector : IControlProjector
{
public string Kind => "Button";
public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
{
var scss = AppearanceScss.Emit(
a,
isRoot: node.ClassName == Document.DesignerDocument.RootClassName,
isContainer: false,
childCount: 0,
isLabel: false,
isCheckbox: false,
checkboxSize: default );
var nodeId = node.Id.ToString();
var ops = new PanelOp[]
{
new SetAttribute( "data-grd-node-id", nodeId ),
new SetInnerText( p.Content ?? "" ),
};
var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };
return new ProjectionResult(
PanelOps: ops,
ScssLines: scss,
RazorAttributes: razorAttrs,
RazorInnerText: Escape.Html( p.Content ?? "" ) );
}
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;
namespace Grains.RazorDesigner.Projection.Projectors;
[Projector( "Field" )]
public sealed class FieldProjector : IControlProjector
{
public string Kind => "Field";
public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
{
var scss = AppearanceScss.Emit(
a,
isRoot: node.ClassName == Document.DesignerDocument.RootClassName,
isContainer: true,
childCount: node.Children.Count,
isLabel: false,
isCheckbox: false,
checkboxSize: default );
var nodeId = node.Id.ToString();
var ops = new PanelOp[]
{
new SetAttribute( "data-grd-node-id", nodeId ),
};
var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };
return new ProjectionResult(
PanelOps: ops,
ScssLines: scss,
RazorAttributes: razorAttrs,
RazorInnerText: null );
}
}
namespace Grains.RazorDesigner.Projection.Razor;
public static class RazorEmit
{
public static string Attr( string name, string value ) => $"{name}=\"{Escape.Html( value )}\"";
}
using System;
namespace Grains.RazorDesigner.Projection.Tests;
public static class PanelOpExhaustivenessTest
{
public static (bool pass, string message) Run()
{
var ops = new PanelOp[]
{
new SetClass( "" ),
new SetStyle( "", "" ),
new SetAttribute( "", "" ),
new SetInnerText( "" ),
};
try
{
foreach ( var op in ops )
Applier.ApplyOpToScratch( op );
return (true, $"PanelOpExhaustivenessTest: {ops.Length} variants OK");
}
catch ( Exception e )
{
return (false, $"PanelOpExhaustivenessTest FAILED: {e.GetType().Name}: {e.Message}");
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Grains.RazorDesigner.Document;
namespace Grains.RazorDesigner.Serialization.IR;
public static class IRWriter
{
private const string LogPrefix = "[Grains.RazorDesigner]";
// Empty collections reused for nodes that have no slots/children/metadata.
private static readonly IReadOnlyDictionary<string, object> _emptyMetadata = new Dictionary<string, object>();
private static readonly IReadOnlyList<IRNodeEnvelope> _emptyChildren = System.Array.Empty<IRNodeEnvelope>();
private static readonly IReadOnlyDictionary<string, IRNodeEnvelope> _emptySlots = new Dictionary<string, IRNodeEnvelope>();
public static string WriteDocument( DesignerDocument doc )
{
if ( doc is null )
throw new ArgumentNullException( nameof( doc ) );
Log.Info( $"{LogPrefix} IRWriter.WriteDocument: serialising document (root children: {doc.RootRecord.Children.Count})" );
var envelope = new IRDocumentEnvelope
{
Root = ToNode( doc.RootRecord ),
Wiring = doc.Wiring ?? Grains.RazorDesigner.Wiring.WiringEnvelope.Empty,
};
var json = JsonSerializer.Serialize( envelope, DesignerIRJson.Options );
// Normalise CRLF → LF (canonical form; .gitattributes also pins LF as a backstop).
if ( json.Contains( '\r' ) )
json = json.Replace( "\r\n", "\n" ).Replace( "\r", "\n" );
Log.Info( $"{LogPrefix} IRWriter.WriteDocument: OK ({json.Length} chars)" );
return json;
}
public static string CanonicalHash( string json )
{
if ( json is null )
throw new ArgumentNullException( nameof( json ) );
var bytes = Encoding.UTF8.GetBytes( json );
var hash = SHA256.HashData( bytes );
return Convert.ToHexString( hash ).ToLowerInvariant();
}
// Recursively converts a ControlRecord to its IRNodeEnvelope representation.
private static IRNodeEnvelope ToNode( ControlRecord r )
{
Dictionary<string, IRNodeEnvelope> slotDict = null;
List<IRNodeEnvelope> childList = null;
foreach ( var child in r.Children )
{
if ( child.IsSlot )
{
slotDict ??= new Dictionary<string, IRNodeEnvelope>();
slotDict[child.SlotName] = ToNode( child );
}
else
{
childList ??= new List<IRNodeEnvelope>();
childList.Add( ToNode( child ) );
}
}
return new IRNodeEnvelope
{
Id = r.Id,
Kind = r.Type,
ClassName = r.ClassName,
Appearance = r.Appearance,
Payload = r.Payload,
Slots = slotDict is not null
? (IReadOnlyDictionary<string, IRNodeEnvelope>)slotDict
: _emptySlots,
Children = childList is not null
? (IReadOnlyList<IRNodeEnvelope>)childList
: _emptyChildren,
States = r.StateRules.Count == 0
? null
: r.StateRules
.OrderBy( rule => rule, Comparer<StateRule>.Create( StateRule.CompareCanonical ) )
.Select( rule => new IRStateEnvelope
{
State = rule.State,
NthChildMode = rule.NthChildMode,
NthChildArg = rule.NthChildArg,
Delta = rule.Delta,
} )
.ToList(),
Bindings = r.Bindings.Count == 0
? System.Array.Empty<Grains.RazorDesigner.Wiring.Binding>()
: r.Bindings.ToArray(),
CustomStyles = r.CustomStyles.Count == 0
? null
: new Dictionary<string, string>( r.CustomStyles ),
};
}
}
using System;
using System.Text.Json.Serialization;
namespace Grains.RazorDesigner.Wiring;
[JsonPolymorphic( TypeDiscriminatorPropertyName = "$type" )]
[JsonDerivedType( typeof( SetAction ), "Set" )]
[JsonDerivedType( typeof( CallAction ), "Call" )]
[JsonDerivedType( typeof( IfAction ), "If" )]
[JsonDerivedType( typeof( StateHasChangedAction ), "StateHasChanged" )]
[JsonDerivedType( typeof( LogAction ), "Log" )]
[JsonDerivedType( typeof( ReturnAction ), "Return" )]
[JsonDerivedType( typeof( InlineAction ), "Inline" )]
public abstract record Action
{
public Guid Id { get; init; } = Guid.NewGuid();
}
namespace Grains.RazorDesigner.Wiring;
public sealed record InlineAction : Action
{
public string Code { get; init; } = "";
}
namespace Grains.RazorDesigner.Wiring;
// `Target = Value;` — assignment to a Symbol field.
public sealed record SetAction : Action
{
public TargetRef Target { get; init; }
public Expression Value { get; init; }
}
using System.Collections.Generic;
namespace Grains.RazorDesigner.Wiring;
public sealed record EventBinding : Binding
{
public string Event { get; init; } = "";
public IReadOnlyList<Action> Body { get; init; } = System.Array.Empty<Action>();
}
namespace Grains.RazorDesigner.Wiring;
public sealed record VisibleBinding : Binding
{
public Expression Condition { get; init; }
}
namespace Grains.RazorDesigner.Wiring;
public enum SymbolVisibility
{
Private,
Public,
Internal,
Protected,
}
using Sandbox;
using Editor;
namespace RedSnail.RoadTool.Editor;
/// <summary>
/// Create and manage road and road intersection.
/// </summary>
[Title("Create Road/Intersection")]
[Icon("roundabout_left")]
[Alias("intersection")]
[Group("1")]
[Order(0)]
public class IntersectionTool : EditorTool
{
public override void OnEnabled()
{
}
public override Widget CreateToolSidebar()
{
ToolSidebarWidget sidebar = new ToolSidebarWidget();
sidebar.AddTitle("Intersection", "roundabout_left");
Layout group = sidebar.AddGroup("Create");
Layout row = Layout.Row();
IconButton road = sidebar.CreateButton("Create Road", "route", null, CreateRoad, true, row);
IconButton inter = sidebar.CreateButton("Create Intersection", "roundabout_left", null, CreateIntersection, true, row);
row.Spacing = 5;
row.AddStretchCell();
group.Add(row);
sidebar.Layout.Add(group);
sidebar.Layout.AddStretchCell();
return sidebar;
}
private static void CreateRoad()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road";
go.AddComponent<RoadComponent>();
}
private static void CreateIntersection()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road Intersection";
go.AddComponent<RoadIntersectionComponent>();
}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace GeneralGame.Editor;
/// <summary>
/// Helper for building asset context menus with Create Material/Texture options.
/// </summary>
public static class AssetContextMenuHelper
{
// Builds the right-click menu for a single file/asset. Shared by the tree view and the icon grid
// so both show identical options. Rename UI differs per view, so it is passed in via onRename.
public static void BuildFileMenu(Menu menu, string fullPath, Asset asset, Action onRename, Action onChanged)
{
var fileName = Path.GetFileName(fullPath);
if (asset != null)
menu.AddOption("Open in Editor", "edit", () => asset.OpenInEditor());
else
menu.AddOption("Open", "open_in_new", () => EditorUtility.OpenFolder(fullPath));
menu.AddOption("Show in Explorer", "folder_open", () => EditorUtility.OpenFileFolder(fullPath));
menu.AddSeparator();
if (asset != null)
menu.AddOption("Copy Relative Path", "content_paste_go", () => EditorUtility.Clipboard.Copy(asset.RelativePath));
menu.AddOption("Copy Absolute Path", "content_paste", () => EditorUtility.Clipboard.Copy(fullPath));
// Asset-type specific options (Create Material, Create Texture, etc.)
AddAssetTypeOptions(menu, asset);
menu.AddSeparator();
menu.AddOption("Rename", "edit", () => onRename?.Invoke());
menu.AddOption("Duplicate", "file_copy", () =>
{
DuplicateFile(fullPath);
onChanged?.Invoke();
});
menu.AddSeparator();
var parentFolder = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(parentFolder))
{
var createMenu = menu.AddMenu("Create", "add");
AssetCreator.AddOptions(createMenu, parentFolder);
menu.AddSeparator();
}
menu.AddOption("Delete", "delete", () =>
{
var confirm = new PopupWindow(
"Delete File",
$"Are you sure you want to delete '{fileName}'?",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
try
{
DeleteFileWithCompiled(fullPath, asset);
onChanged?.Invoke();
}
catch (Exception ex)
{
Log.Error($"Failed to delete file: {ex.Message}");
}
}
}
}
);
confirm.Show();
});
}
// Builds the right-click menu for a folder. Shared by the tree view and the icon grid.
public static void BuildFolderMenu(Menu menu, string fullPath, string displayName, bool isRoot,
Action onOpen, Action onRename, Action onRefresh, Action onDeleted)
{
if (onOpen != null)
menu.AddOption("Open", "folder_open", () => onOpen());
menu.AddOption("Open in Explorer", "launch", () => EditorUtility.OpenFolder(fullPath));
menu.AddSeparator();
var createMenu = menu.AddMenu("Create", "add");
AssetCreator.AddOptions(createMenu, fullPath);
// Paste files copied from Windows Explorer into this folder
if (WindowsClipboard.HasFiles())
{
menu.AddOption("Paste", "content_paste", () =>
{
PasteFromClipboard(fullPath);
onRefresh?.Invoke();
});
}
menu.AddSeparator();
if (!isRoot)
menu.AddOption("Rename", "edit", () => onRename?.Invoke());
menu.AddOption("Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy(fullPath));
menu.AddOption("Copy Relative Path", "content_copy", () =>
{
var relativePath = Path.GetRelativePath(Project.Current?.GetRootPath() ?? "", fullPath);
EditorUtility.Clipboard.Copy(relativePath);
});
menu.AddSeparator();
menu.AddOption("Refresh", "refresh", () => onRefresh?.Invoke());
if (!isRoot)
{
menu.AddSeparator();
menu.AddOption("Delete", "delete", () =>
{
var confirm = new PopupWindow(
"Delete Folder",
$"Are you sure you want to delete '{displayName}'?\nAll contents will be deleted.",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
try
{
Directory.Delete(fullPath, recursive: true);
onDeleted?.Invoke();
}
catch (Exception ex)
{
Log.Error($"Failed to delete folder: {ex.Message}");
}
}
}
}
);
confirm.Show();
});
}
}
// Duplicates a file next to itself, finding a free "_copy" name.
public static void DuplicateFile(string filePath)
{
try
{
var directory = Path.GetDirectoryName(filePath);
var nameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var extension = Path.GetExtension(filePath);
var newName = $"{nameWithoutExt}_copy{extension}";
var newPath = Path.Combine(directory, newName);
var counter = 1;
while (File.Exists(newPath))
{
newName = $"{nameWithoutExt}_copy{counter++}{extension}";
newPath = Path.Combine(directory, newName);
}
File.Copy(filePath, newPath);
// Register the new file so it gets compiled and recognised as an asset immediately
// (without this it shows up uncompiled until an editor restart or rename).
AssetSystem.RegisterFile(newPath);
}
catch (Exception ex)
{
Log.Error($"Failed to duplicate file: {ex.Message}");
}
}
// Registers a freshly created/copied/moved path with the asset system so it compiles right away.
// Accepts a single file or a directory (registers every file inside, recursively).
public static void RegisterNewPath(string path)
{
try
{
if (Directory.Exists(path))
{
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
if (ShouldRegister(file))
AssetSystem.RegisterFile(file);
}
}
else if (File.Exists(path) && ShouldRegister(path))
{
AssetSystem.RegisterFile(path);
}
}
catch (Exception ex)
{
Log.Warning($"Failed to register '{path}': {ex.Message}");
}
}
// Copies the files currently on the Windows clipboard into the target folder (always copy, never move).
public static void PasteFromClipboard(string targetFolder)
{
if (string.IsNullOrEmpty(targetFolder) || !Directory.Exists(targetFolder))
return;
foreach (var file in WindowsClipboard.GetFiles())
{
if (string.IsNullOrEmpty(file))
continue;
try
{
var name = Path.GetFileName(file.TrimEnd('\\', '/'));
var destPath = Path.Combine(targetFolder, name);
// Don't paste a folder into itself
if (Path.GetFullPath(file).Equals(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
destPath = MakeUniquePath(destPath);
else if (File.Exists(destPath) || Directory.Exists(destPath))
destPath = MakeUniquePath(destPath);
if (Directory.Exists(file))
CopyDirectory(file, destPath);
else if (File.Exists(file))
File.Copy(file, destPath, overwrite: false);
else
continue;
RegisterNewPath(destPath);
}
catch (Exception ex)
{
Log.Error($"Failed to paste '{file}': {ex.Message}");
}
}
}
private static string MakeUniquePath(string path)
{
if (!File.Exists(path) && !Directory.Exists(path))
return path;
var directory = Path.GetDirectoryName(path);
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
int counter = 1;
string candidate;
do
{
var suffix = counter == 1 ? "_copy" : $"_copy{counter}";
candidate = Path.Combine(directory, $"{name}{suffix}{ext}");
counter++;
}
while (File.Exists(candidate) || Directory.Exists(candidate));
return candidate;
}
private static void CopyDirectory(string sourceDir, string destDir)
{
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(sourceDir))
{
File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)));
}
foreach (var dir in Directory.GetDirectories(sourceDir))
{
CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
}
}
// True when the path lives outside the current project (e.g. dragged in from Windows Explorer).
// Such sources must be copied, never moved, so we don't delete files from their original location.
public static bool IsExternalSource(string path)
{
try
{
var root = Project.Current?.GetRootPath();
if (string.IsNullOrEmpty(root))
return false;
var full = Path.GetFullPath(path);
var rootFull = Path.GetFullPath(root);
return !full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
// ===== Backward compatibility: move an asset and optionally fix references to its old path =====
private static readonly HashSet<string> NonTextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".tga", ".psd", ".bmp", ".gif", ".dds", ".hdr",
".fbx", ".obj", ".gltf", ".glb", ".dmx", ".blend",
".wav", ".mp3", ".ogg", ".flac",
".vtex", ".vsnd", ".vmdl", ".dll", ".pdb", ".exe", ".zip", ".bin"
};
// True if this is a single registered asset being moved inside the project - the case the
// backward-compatibility reference check applies to.
public static bool IsBackwardCompatMove(IReadOnlyList<string> files, bool isCopy)
{
if (!BrowserSettings.BackwardCompatibility) return false;
if (isCopy) return false;
if (files == null || files.Count != 1) return false;
var file = files[0];
if (string.IsNullOrEmpty(file) || IsExternalSource(file) || Directory.Exists(file)) return false;
if (!File.Exists(file)) return false;
var asset = AssetSystem.FindByPath(file);
return asset != null && !asset.IsDeleted;
}
// Moves an asset, first asking (via a modal) whether to update references to its old path.
public static void MoveAssetWithReferenceCheck(string sourceFile, string targetFolder, Action onComplete)
{
var asset = AssetSystem.FindByPath(sourceFile);
if (asset == null || asset.IsDeleted)
return;
// Don't bother if it's already in the target folder
if (string.Equals(Path.GetFullPath(Path.GetDirectoryName(sourceFile)), Path.GetFullPath(targetFolder), StringComparison.OrdinalIgnoreCase))
return;
var assetsRoot = Project.Current?.GetAssetsPath();
var oldRef = asset.RelativePath?.Replace('\\', '/');
var newAbs = Path.Combine(targetFolder, Path.GetFileName(sourceFile));
var newRef = string.IsNullOrEmpty(assetsRoot)
? null
: Path.GetRelativePath(assetsRoot, newAbs).Replace('\\', '/');
void JustMove()
{
EditorUtility.MoveAssetToDirectory(asset, targetFolder);
onComplete?.Invoke();
}
// Can't compute a clean reference (asset outside the assets mount) - just move normally
if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || newRef.StartsWith(".."))
{
JustMove();
return;
}
var affected = FindFilesReferencing(oldRef);
affected.RemoveAll(f => string.Equals(Path.GetFullPath(f), Path.GetFullPath(asset.AbsolutePath), StringComparison.OrdinalIgnoreCase));
var dialog = new MoveReferencesDialog(oldRef, newRef, affected,
onJustMove: JustMove,
onMoveAndUpdate: () =>
{
EditorUtility.MoveAssetToDirectory(asset, targetFolder);
RewriteReferences(affected, oldRef, newRef);
onComplete?.Invoke();
});
dialog.Show();
}
// Matches the reference only at a path boundary, so "models/foo.vmdl" doesn't match inside
// "othermodels/foo.vmdl" or "sub/models/foo.vmdl". A trailing "_c" (compiled form) is still matched.
private static Regex BuildReferenceRegex(string reference)
{
return new Regex(@"(?<![\w./\\-])" + Regex.Escape(reference), RegexOptions.IgnoreCase);
}
// Finds every text-based project file that mentions the given reference path.
public static List<string> FindFilesReferencing(string reference)
{
var results = new List<string>();
if (string.IsNullOrEmpty(reference))
return results;
var root = Project.Current?.GetAssetsPath();
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
return results;
var regex = BuildReferenceRegex(reference);
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
if (!IsScannableTextFile(file))
continue;
try
{
var info = new FileInfo(file);
if (info.Length == 0 || info.Length > 16_000_000)
continue;
var text = File.ReadAllText(file);
if (regex.IsMatch(text))
results.Add(file);
}
catch
{
// Ignore unreadable files
}
}
return results;
}
// Rewrites the old reference to the new one in each file (covers compiled "_c" forms too, since
// they share the prefix). Re-registers the file afterwards.
public static void RewriteReferences(IEnumerable<string> files, string oldRef, string newRef)
{
var regex = BuildReferenceRegex(oldRef);
foreach (var file in files)
{
try
{
var text = File.ReadAllText(file);
var updated = regex.Replace(text, _ => newRef);
if (updated != text)
{
File.WriteAllText(file, updated);
AssetSystem.RegisterFile(file);
}
}
catch (Exception ex)
{
Log.Error($"Failed to update references in '{file}': {ex.Message}");
}
}
}
private static bool IsScannableTextFile(string file)
{
if (!ShouldRegister(file))
return false;
var ext = Path.GetExtension(file);
if (NonTextExtensions.Contains(ext))
return false;
return true;
}
private static bool ShouldRegister(string file)
{
var name = Path.GetFileName(file);
if (name.StartsWith(".")) return false;
if (name.EndsWith("_c", StringComparison.OrdinalIgnoreCase)) return false;
if (name.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false;
if (name.Contains(".generated", StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
// Deletes a file, also removing its compiled "_c" sibling. Uses Asset.Delete when registered.
public static void DeleteFileWithCompiled(string fullPath, Asset asset)
{
if (asset != null)
{
asset.Delete();
return;
}
File.Delete(fullPath);
var compiledPath = fullPath + "_c";
if (File.Exists(compiledPath))
{
File.Delete(compiledPath);
}
}
private static readonly HashSet<string> MeshExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".fbx", ".obj", ".dmx", ".gltf", ".glb" };
// Builds the right-click menu shown when several files are selected at once.
// Shared by the tree view and the icon grid so both behave identically.
public static void BuildMultiFileMenu(Menu menu, List<(string Path, Asset Asset)> items, Action onChanged)
{
if (items == null || items.Count == 0) return;
int count = items.Count;
var assets = items.Where(i => i.Asset != null).Select(i => i.Asset).ToList();
// Asset-type batch options (Create Material (N), Create Texture (N), etc.)
bool addedTypeOptions = AddMultiAssetTypeOptions(menu, assets);
if (addedTypeOptions)
menu.AddSeparator();
menu.AddOption($"Duplicate ({count})", "file_copy", () =>
{
foreach (var it in items)
DuplicateFile(it.Path);
onChanged?.Invoke();
});
menu.AddOption($"Delete ({count})", "delete", () =>
{
var confirm = new PopupWindow(
"Delete Files",
$"Are you sure you want to delete {count} item(s)?",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
foreach (var it in items)
{
try
{
DeleteFileWithCompiled(it.Path, it.Asset);
}
catch (Exception ex)
{
Log.Error($"Failed to delete '{it.Path}': {ex.Message}");
}
}
onChanged?.Invoke();
}
}
}
);
confirm.Show();
});
}
// Adds asset-type specific batch options with a count suffix, e.g. "Create Material (3)".
// Each option auto-creates the result next to every source asset (no save dialog).
// Returns true if any option was added.
public static bool AddMultiAssetTypeOptions(Menu menu, List<Asset> assets)
{
if (assets == null || assets.Count == 0) return false;
var images = assets.Where(a => a.AssetType == AssetType.ImageFile).ToList();
var shaders = assets.Where(a => a.AssetType == AssetType.Shader).ToList();
var meshes = assets.Where(a => MeshExtensions.Contains(Path.GetExtension(a.AbsolutePath))).ToList();
bool added = false;
if (images.Count > 0)
{
menu.AddOption($"Create Material ({images.Count})", "image", () =>
{
foreach (var a in images) CreateMaterialFromImageAuto(a);
Log.Info($"Created {images.Count} material(s)");
});
menu.AddOption($"Create Texture ({images.Count})", "texture", () =>
{
foreach (var a in images) CreateTextureFromImageAuto(a);
});
menu.AddOption($"Create Sprite ({images.Count})", "emoji_emotions", () =>
{
foreach (var a in images) CreateSpriteFromImageAuto(a);
});
added = true;
}
if (shaders.Count > 0)
{
if (added) menu.AddSeparator();
menu.AddOption($"Create Material ({shaders.Count})", "image", () =>
{
foreach (var a in shaders) CreateMaterialFromShaderAuto(a);
});
added = true;
}
if (meshes.Count > 0)
{
if (added) menu.AddSeparator();
menu.AddOption($"Create Model ({meshes.Count})", "view_in_ar", () =>
{
foreach (var a in meshes) CreateModelFromMeshAuto(a);
});
added = true;
}
var sounds = assets.Where(a => a.AssetType == AssetType.SoundFile).ToList();
if (sounds.Count > 0)
{
if (added) menu.AddSeparator();
// One sound event per file
menu.AddOption($"Create Sound Event ({sounds.Count})", "graphic_eq", () =>
{
foreach (var a in sounds) CreateSoundEventFromAudioAuto(a);
Log.Info($"Created {sounds.Count} sound event(s)");
});
// A single sound event that randomly picks between all the selected sounds
menu.AddOption($"Create Random Sound Event ({sounds.Count})", "shuffle", () =>
{
CreateRandomSoundEventFromAudios(sounds);
});
added = true;
}
return added;
}
/// <summary>
/// Add asset-type specific options like Create Material, Create Texture, etc.
/// </summary>
public static void AddAssetTypeOptions(Menu menu, Asset asset)
{
if (asset == null) return;
var assetType = asset.AssetType;
if (assetType == null) return;
// Image files - can create Material, Texture, Sprite
if (assetType == AssetType.ImageFile)
{
menu.AddSeparator();
menu.AddOption("Create Material", "image", () => CreateMaterialFromImage(asset));
menu.AddOption("Create Texture", "texture", () => CreateTextureFromImage(asset));
menu.AddOption("Create Sprite", "emoji_emotions", () => CreateSpriteFromImage(asset));
}
// Shader files - can create Material
if (assetType == AssetType.Shader)
{
menu.AddSeparator();
menu.AddOption("Create Material", "image", () => CreateMaterialFromShader(asset));
}
// Mesh files (FBX, OBJ) - can create Model
if (MeshExtensions.Contains(Path.GetExtension(asset.AbsolutePath)))
{
menu.AddSeparator();
menu.AddOption("Create Model", "view_in_ar", () => CreateModelFromMesh(asset));
}
// Sound files - can create a Sound Event
if (assetType == AssetType.SoundFile)
{
menu.AddSeparator();
menu.AddOption("Create Sound Event", "graphic_eq", () => CreateSoundEventFromAudio(asset));
}
}
private static void CreateTextureFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Texture from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vtex";
fd.SelectFile($"{asset.Name}.vtex");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Texture File (*.vtex)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildVtexContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static void CreateMaterialFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Material from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{GetMaterialBaseName(asset)}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildImageMaterialContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static async void CreateSpriteFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Sprite from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".sprite";
fd.SelectFile($"{asset.Name}.sprite");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Sprite File (*.sprite)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildSpriteContent(asset));
var resultAsset = AssetSystem.RegisterFile(fd.SelectedFile);
while (!resultAsset.IsCompiledAndUpToDate)
{
await Task.Delay(10);
}
}
private static void CreateMaterialFromShader(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Material from Shader..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{asset.Name}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildShaderMaterialContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static void CreateModelFromMesh(Asset asset)
{
var targetPath = EditorUtility.SaveFileDialog("Create Model..", "vmdl", Path.ChangeExtension(asset.AbsolutePath, "vmdl"));
if (targetPath == null)
return;
EditorUtility.CreateModelFromMeshFile(asset, targetPath);
}
private static void CreateSoundEventFromAudio(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Sound Event..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".sound";
fd.SelectFile($"{asset.Name}.sound");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Sound Event (*.sound)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
AssetSystem.RegisterFile(fd.SelectedFile);
}
// ===== Batch creators: auto-name next to each source asset, skip if it already exists =====
private static void CreateMaterialFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{GetMaterialBaseName(asset)}.vmat");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildImageMaterialContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static void CreateTextureFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.vtex");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildVtexContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static async void CreateSpriteFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.sprite");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildSpriteContent(asset));
var resultAsset = AssetSystem.RegisterFile(destPath);
while (!resultAsset.IsCompiledAndUpToDate)
{
await Task.Delay(10);
}
}
private static void CreateMaterialFromShaderAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.vmat");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildShaderMaterialContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static void CreateModelFromMeshAuto(Asset asset)
{
var destPath = Path.ChangeExtension(asset.AbsolutePath, "vmdl");
if (File.Exists(destPath))
return;
EditorUtility.CreateModelFromMeshFile(asset, destPath);
}
private static void CreateSoundEventFromAudioAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.sound");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
AssetSystem.RegisterFile(destPath);
}
// Creates a single sound event that randomly picks between all the given audio files.
private static void CreateRandomSoundEventFromAudios(List<Asset> assets)
{
if (assets == null || assets.Count == 0)
return;
var first = assets[0];
var directory = Path.GetDirectoryName(first.AbsolutePath);
var destPath = FindFreePath(directory, GetSoundBaseName(first), ".sound");
var references = assets.Select(GetVsndReference).ToList();
File.WriteAllText(destPath, BuildSoundEventContent(references));
AssetSystem.RegisterFile(destPath);
}
// ===== Content builders shared by single and batch creators =====
// Strips trailing texture-role suffixes (_color, _normal, ...) to get the material base name.
private static string GetMaterialBaseName(Asset asset)
{
string[] suffixes = { "color", "ao", "normal", "metallic", "rough", "diff", "diffuse", "nrm", "spec", "selfillum", "mask" };
var assetName = asset.Name;
foreach (var t in suffixes)
{
if (assetName.EndsWith($"_{t}"))
assetName = assetName.Substring(0, assetName.Length - (t.Length + 1));
}
return assetName;
}
// Builds a complex.shader material, auto-wiring sibling textures (normal/ao/rough/...) by name.
private static string BuildImageMaterialContent(Asset asset)
{
var assetName = GetMaterialBaseName(asset);
var assetPath = Path.GetDirectoryName(asset.AbsolutePath).NormalizeFilename(false);
var assetPeers = AssetSystem.All
.Where(x => x.AssetType == AssetType.ImageFile)
.Where(x => x.AbsolutePath.StartsWith(assetPath))
.ToArray();
var assetPeersWithSameBaseName = assetPeers
.Where(x => x.Name == assetName || x.Name.StartsWith(assetName + "_"))
.ToArray();
if (assetPeersWithSameBaseName.Length > 0)
{
assetPeers = assetPeersWithSameBaseName;
}
string texColor = assetPeers.Where(x => x.Name.Contains("_color") || x.Name.Contains("_diff")).Select(x => x.RelativePath).FirstOrDefault();
texColor ??= asset.RelativePath;
string texNormal = assetPeers.Where(x => x.Name.Contains("_nrm") || x.Name.Contains("_normal") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_normal.tga";
string texAo = assetPeers.Where(x => x.Name.Contains("_ao") || x.Name.Contains("_occ") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_ao.tga";
string texRough = assetPeers.Where(x => x.Name.Contains("_rough")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_rough.tga";
string texMetallic = assetPeers.Where(x => x.Name.Contains("_metallic")).Select(x => x.RelativePath).FirstOrDefault();
if (texMetallic != null)
{
texMetallic = $"\n\tF_METALNESS_TEXTURE 1\n\tF_SPECULAR 1\n\tTextureMetalness \"{texMetallic}\"";
}
string texSelfIllum = assetPeers.Where(x => x.Name.Contains("_selfillum")).Select(x => x.RelativePath).FirstOrDefault();
if (texSelfIllum != null)
{
texSelfIllum = $"\n\tF_SELF_ILLUM 1\n\tTextureSelfIllumMask \"{texSelfIllum}\"";
}
string tintMask = assetPeers.Where(x => x.Name.Contains("_mask")).Select(x => x.RelativePath).FirstOrDefault();
if (tintMask != null)
{
tintMask = $"\n\tF_TINT_MASK 1\n\tTextureTintMask \"{tintMask}\"";
}
return $@"
Layer0
{{
shader ""shaders/complex.shader_c""
TextureColor ""{texColor}""
TextureAmbientOcclusion ""{texAo}""
TextureNormal ""{texNormal}""
TextureRoughness ""{texRough}""{texMetallic}{texSelfIllum}{tintMask}
}}
";
}
private static string BuildShaderMaterialContent(Asset asset)
{
var shaderPath = asset.GetCompiledFile();
return $@"
Layer0
{{
shader ""{shaderPath}""
}}
";
}
private static string BuildVtexContent(Asset asset)
{
var vtexContent = new Dictionary<string, object>
{
{ "Sequences", new object[]
{
new Dictionary<string, object>
{
{ "Source", asset.RelativePath },
{ "IsLooping", true }
}
}
}
};
return Json.Serialize(vtexContent);
}
private static string BuildSpriteContent(Asset asset)
{
var path = Path.ChangeExtension(asset.Path, Path.GetExtension(asset.AbsolutePath));
var sprite = Sprite.FromTexture(Texture.Load(path));
return sprite.Serialize().ToJsonString();
}
// Builds a .sound (SoundEvent) resource that plays a random sound from the given references.
private static string BuildSoundEventContent(IEnumerable<string> vsndReferences)
{
var content = new Dictionary<string, object>
{
{ "Volume", "1" },
{ "Pitch", "1" },
{ "SelectionMode", "Random" },
{ "Sounds", vsndReferences.ToArray() },
{ "__version", 1 }
};
return Json.Serialize(content);
}
// Sound events reference the compiled .vsnd, regardless of the source file extension.
private static string GetVsndReference(Asset asset)
{
return Path.ChangeExtension(asset.RelativePath, "vsnd").Replace('\\', '/');
}
// Strips a trailing numeric index so "footstep_01" -> "footstep" for naming a combined event.
private static string GetSoundBaseName(Asset asset)
{
var name = asset.Name;
var underscore = name.LastIndexOf('_');
if (underscore > 0 && int.TryParse(name.Substring(underscore + 1), out _))
name = name.Substring(0, underscore);
return name;
}
private static string FindFreePath(string directory, string baseName, string extension)
{
var path = Path.Combine(directory, $"{baseName}{extension}");
int counter = 1;
while (File.Exists(path))
path = Path.Combine(directory, $"{baseName}_{counter++}{extension}");
return path;
}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
global using Dreams.UltimateLightManager;
using Editor;
using Sandbox;
using System.Linq;
namespace SFXR.Editor;
//[CustomEditor( typeof( SFXRComponent ) )]
public class SFXRComponentEditor : ComponentEditorWidget
{
//ParticleFloatControlWidget
public SFXRComponentEditor( SerializedObject obj ) : base( obj )
{
var defaultInspector = new PropertyControlSheet();
defaultInspector.AddObject( obj );
Layout = Layout.Column();
Layout.Add( defaultInspector );
Layout.AddSpacingCell( 5 );
}
}using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SFXR.Editor;
//[CustomEditor( typeof( SFXRFloat ) )]
// public class SFXRFloatControlWidget : ControlWidget
// {
// SerializedObject Target;
// public SFXRFloatControlWidget( SerializedProperty property ) : base( property )
// {
// SetSizeMode( SizeMode.Ignore, SizeMode.Default );
// if ( !property.TryGetAsObject( out Target ) )
// return;
// Layout = Layout.Row();
// Layout.Spacing = 2;
// var value = Target.GetProperty( "Value" );
// FloatControlWidget valueWidget = new FloatControlWidget( value );
// if ( property.TryGetAttribute<RangeAttribute>( out var attribute ) )
// {
// valueWidget.Range = new Vector2( attribute.Min, attribute.Max );
// valueWidget.RangeStep = attribute.Step;
// valueWidget.HasRange = attribute.Slider;
// // if ( attribute.Slider )
// // {
// // sliderWidget = new FloatSlider( this );
// // sliderWidget.HighlightColor = Theme.Grey;
// // sliderWidget.Minimum = attribute.Min;
// // sliderWidget.Maximum = attribute.Max;
// // sliderWidget.Step = attribute.S;
// // sliderWidget.OnValueEdited = delegate
// // {
// // base.SerializedProperty.As.Float = sliderWidget.Value;
// // };
// // }
// }
// Layout.Add( valueWidget );
// var locked = Target.GetProperty( "Locked" );
// IconButton lockedButton = new IconButton( "lock_open" );
// lockedButton.OnClick = () =>
// {
// if ( Target.Targets.First() is SFXRFloat sfxrFloat )
// {
// sfxrFloat.Locked = !sfxrFloat.Locked;
// Log.Info( sfxrFloat.Locked );
// lockedButton.Icon = sfxrFloat.Locked ? "lock" : "lock_open";
// lockedButton.Update();
// }
// };
// lockedButton.ToolTip = "Lock/Unlock this value";
// Layout.Add( lockedButton );
// }
// protected override void OnPaint()
// {
// }
// }
// public class SFXRFloatControlWidget : FloatControlWidget
// {
// SerializedObject Target;
// public SFXRFloatControlWidget( SerializedProperty property ) : base( property )
// {
// if ( !property.TryGetAsObject( out Target ) )
// return;
// var locked = Target.GetProperty( "Locked" );
// IconButton lockedButton = new IconButton( "lock_open" );
// lockedButton.OnClick = () =>
// {
// // if ( Target.Targets.First() is SFXRFloat sfxrFloat )
// // {
// // sfxrFloat.Locked = !sfxrFloat.Locked;
// // Log.Info( sfxrFloat.Locked );
// // lockedButton.Icon = sfxrFloat.Locked ? "lock" : "lock_open";
// // lockedButton.Update();
// // }
// };
// lockedButton.ToolTip = "Lock/Unlock this value";
// //Layout.Add( lockedButton );
// }
// protected override void PaintControl()
// {
// if ( Target is null ) return;
// base.PaintControl();
// }
// protected override void OnPaint()
// {
// if ( Target is null ) return;
// base.OnPaint();
// }
// }using Editor;
using Sandbox;
using System.Linq;
namespace SFXR.Editor;
[CustomEditor( typeof( SFXRControls ) )]
public class SFXRSoundControlWidget : ControlWidget
{
SerializedObject Target;
public SFXRSoundControlWidget( SerializedProperty property ) : base( property )
{
if ( !property.TryGetAsObject( out Target ) )
return;
var component = property.Parent.Targets.First() as SFXRComponent;
// Randomize Button
var btnRandomize = new Button( "Randomize All", "casino" );
btnRandomize.Clicked = () =>
{
component.Randomize();
component.PlaySound();
};
btnRandomize.ToolTip = "Randomize all sound properties";
// Play Sound Button
var btnPlaySound = new Button( "Play Sound", "play_arrow" );
btnPlaySound.Clicked = () =>
{
component.PlaySound();
};
btnPlaySound.MinimumWidth = 200;
btnPlaySound.ToolTip = "Play the current sound";
// Mutate Button
var btnMutate = new Button( "Mutate", "shuffle" );
btnMutate.Clicked = () =>
{
component.Mutate();
component.PlaySound();
};
btnMutate.ToolTip = "Mutate all sound properties";
// Randomize Pickup Button
var btnRandomizePickup = new Button( "", "monetization_on" );
btnRandomizePickup.Clicked = () =>
{
component.RandomizePickup();
component.PlaySound();
};
btnRandomizePickup.ToolTip = "Generate random pickup sound";
// Randomize Laser Button
var btnRandomizeLaser = new Button( "", "bolt" );
btnRandomizeLaser.Clicked = () =>
{
component.RandomizeLaser();
component.PlaySound();
};
btnRandomizeLaser.ToolTip = "Generate random laser sound";
// Randomize Explosion Button
var btnRandomizeExplosion = new Button( "", "flare" );
btnRandomizeExplosion.Clicked = () =>
{
component.RandomizeExplosion();
component.PlaySound();
};
btnRandomizeExplosion.ToolTip = "Generate random explosion sound";
// Randomize Powerup Button
var btnRandomizePowerup = new Button( "", "star" );
btnRandomizePowerup.Clicked = () =>
{
component.RandomizePowerup();
component.PlaySound();
};
btnRandomizePowerup.ToolTip = "Generate random powerup sound";
// Randomize Hit Hurt Button
var btnRandomizeHit = new Button( "", "sentiment_very_dissatisfied" );
btnRandomizeHit.Clicked = () =>
{
component.RandomizeHit();
component.PlaySound();
};
btnRandomizeHit.ToolTip = "Generate random hit/hurt sound";
// Randomize Jump Button
var btnRandomizeJump = new Button( "", "settings_accessibility" );
btnRandomizeJump.Clicked = () =>
{
component.RandomizeJump();
component.PlaySound();
};
btnRandomizeJump.ToolTip = "Generate random jump sound";
// Randomize Blip Select Button
var btnRandomizeBlipSelect = new Button( "", "menu" );
btnRandomizeBlipSelect.Clicked = () =>
{
component.RandomizeBlip();
component.PlaySound();
};
btnRandomizeBlipSelect.ToolTip = "Generate random blip/select sound";
Layout = Layout.Column();
Layout.Spacing = 2;
Layout.Margin = new Sandbox.UI.Margin( 0, 4 );
MinimumHeight = 90;
var grid = Layout.Grid();
grid.Spacing = 2;
grid.AddCell( 0, 0, btnRandomizePickup );
grid.AddCell( 1, 0, btnRandomizeLaser );
grid.AddCell( 2, 0, btnRandomizeExplosion );
grid.AddCell( 3, 0, btnRandomizePowerup );
grid.AddCell( 4, 0, btnRandomizeHit );
grid.AddCell( 5, 0, btnRandomizeJump );
grid.AddCell( 6, 0, btnRandomizeBlipSelect );
var randomRow = Layout.Row();
randomRow.Spacing = 2;
randomRow.Add( btnRandomize );
randomRow.Add( btnMutate );
Layout.Add( grid );
Layout.Add( randomRow );
Layout.Add( btnPlaySound );
}
protected override void OnPaint()
{
}
}using Sandbox;
namespace Editor;
[GameResource( "Motivation", "motivate", "Citizens motivate you every 15-30 minutes.", Icon = "support_agent", Category = "Editor", IconBgColor = "#E4E2E4", IconFgColor = "#93BDDD" )]
public class MotivationResource : GameResource
{
[ImageAssetPath]
public string[] Portraits { get; set; }
public string[] Messages { get; set; }
/// <summary>
/// Selects a random citizen portrait from this type.
/// </summary>
/// <returns>Portrait file path</returns>
public string GetPortrait()
{
var portraitPath = Game.Random.FromArray( Portraits );
return FileSystem.Mounted.GetFullPath( portraitPath );
}
/// <summary>
/// Selects a random motivational response from this type.
/// </summary>
/// <returns>Response with linebreaks</returns>
public string GetMessage()
{
return Game.Random.FromArray( Messages );
}
}
using Sandbox;
using Editor;
public class ReconnecterBar : ToolbarGroup
{
[Event("tools.headerbar.build", Priority = 150)]
public static void OnBuildHeaderToolbar(HeadBarEvent e)
{
e.RightCenter.Add(new ReconnecterBar(null));
e.RightCenter.AddSpacingCell(8);
}
public ReconnecterBar(Widget parent) : base(parent, "Reconnecter", null)
{
ToolTip = "Auto Reconnect Clients";
}
public override void Build()
{
AddToggleButton("Auto Reconnect Clients", "autorenew", () => ReconnecterEditor.autoReconnectEnabled, SetAutoReconnect);
AddToggleButton("Allow Instance Launching", "person_add", () => ReconnecterEditor.allowLaunchInstance, SetAllowLaunchInstance);
AddButton("Force Reconnect Clients", "group", ForceAutoReconnect);
}
public void SetAutoReconnect(bool enabled)
{
ReconnecterEditor.autoReconnectEnabled = enabled;
}
public void SetAllowLaunchInstance(bool enabled)
{
ReconnecterEditor.allowLaunchInstance = enabled;
}
public void ForceAutoReconnect()
{
ReconnecterEditor.CreateSessionText(true);
}
}using Sandbox;
using Sandbox.Internal;
using System.Diagnostics;
using System.IO;
public static class ReconnecterEditor
{
public static string packageFolders => $"{Project.Current.Package.Org.Ident}/{Project.Current.Package.Ident}";
public static string dataFolder => Editor.FileSystem.Root.GetFullPath($"/data/{packageFolders}");
public static bool autoReconnectEnabled
{
get
{
return GlobalToolsNamespace.EditorCookie.Get<bool>("reconnecter_enabled", true);
}
set
{
GlobalToolsNamespace.EditorCookie.Set<bool>("reconnecter_enabled", value);
}
}
public static bool allowLaunchInstance
{
get
{
return GlobalToolsNamespace.EditorCookie.Get<bool>("reconnecter_allowLaunchInstance", true);
}
set
{
GlobalToolsNamespace.EditorCookie.Set<bool>("reconnecter_allowLaunchInstance", value);
}
}
static ReconnecterEditor()
{
ReconnecterSystem.RegisterOnRequestWriteSession(CreateSessionText);
}
public static void CreateSessionText(bool force = false)
{
if (!autoReconnectEnabled && !force)
{
return;
}
string filePath = $"{dataFolder}/{ReconnecterSystem.SESSION_FILE_PATH}";
string destinationDirectory = Path.GetDirectoryName(filePath);
if (!Directory.Exists(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
File.WriteAllText(filePath, System.DateTime.UtcNow.ToString());
//Log.Info($"CreateSessionText() filePath: {filePath}");
}
[Event("scene.play", Priority = int.MinValue)]
public static void ScenePlay()
{
ReconnecterSystem.OnPlayInEditor();
if (!autoReconnectEnabled || !allowLaunchInstance)
{
return;
}
Process[] processes = Process.GetProcessesByName("sbox");
bool isProcessRunning = processes.Length > 0;
if (isProcessRunning)
{
return;
}
SpawnProcess();
}
public static void SpawnProcess()
{
var p = new Process();
p.StartInfo.FileName = "sbox.exe";
p.StartInfo.WorkingDirectory = System.Environment.CurrentDirectory;
p.StartInfo.CreateNoWindow = true;
p.StartInfo.RedirectStandardOutput = true;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.UseShellExecute = false;
p.StartInfo.ArgumentList.Add("-joinlocal");
// This doesn't seem to work because it doesn't use Steam's Launch Options?
p.StartInfo.ArgumentList.Add("-sw");
p.Start();
}
}using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Reflection;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace Editor;
public static class SoundAddons
{
private static string previousSoundGenerationPromp;
private static string previousSoundGenerationDuration;
// [ContextMenuFor( typeof( SoundFile ), "Generate sound", "create" )]
public static void Generate( SoundEvent soundEvent, Action<SoundFile> finished )
{
string defaultInput = !string.IsNullOrEmpty( previousSoundGenerationPromp )
? previousSoundGenerationPromp
: "A dog barking";
Dialog.AskString( ( description ) =>
{
previousSoundGenerationPromp = description;
Dialog.AskString( async ( durationStr ) =>
{
previousSoundGenerationDuration = durationStr;
if ( !float.TryParse( durationStr, out float duration ) || duration < 0.5f || duration > 22f )
{
Log.Error( "Duration must be between 0.5 and 22 seconds" );
return;
}
try
{
string apiKey = SoundSettings.Settings.ElevenLabsApiKey;
var client = new HttpClient();
client.DefaultRequestHeaders.Add( "xi-api-key", apiKey );
var content = new StringContent( JsonSerializer.Serialize( new
{
text = description,
duration_seconds = duration,
prompt_influence = 0.3
} ), Encoding.UTF8, "application/json" );
var response = await client.PostAsync(
"https://api.elevenlabs.io/v1/sound-generation",
content
);
if ( response.IsSuccessStatusCode )
{
var bytes = await response.Content.ReadAsByteArrayAsync();
string safeFileName = description.Replace( " ", "_" ).Replace( "/", "_" ).Replace( "\\", "_" );
var mp3Path = Path.Combine(
SoundSettings.Settings.GenerationPath,
$"{safeFileName}_{DateTime.Now:yyyyMMddHHmmss}.mp3"
);
var fullMp3Path = Path.Combine( Project.Current.GetAssetsPath(), mp3Path );
Directory.CreateDirectory( Path.GetDirectoryName( fullMp3Path ) );
await File.WriteAllBytesAsync( fullMp3Path, bytes );
Log.Info( "File saved to: " + fullMp3Path );
var mp3Asset = AssetSystem.RegisterFile( fullMp3Path );
if ( mp3Asset != null )
{
var soundFile = SoundFile.Load( mp3Asset.RelativePath );
if ( soundFile != null )
{
finished(soundFile);
Log.Info( $"Successfully generated and linked sound: {mp3Asset.RelativePath}" );
}
else
{
Log.Error( "Failed to load generated sound file" );
}
}
else
{
Log.Error( "Failed to register generated mp3 file as asset" );
}
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
Log.Error( $"Failed to generate sound: {response.StatusCode}. Error: {errorContent}" );
}
}
catch ( Exception ex )
{
Log.Error( $"Error generating sound: {ex.Message}" );
}
},
"Enter duration in seconds (0.5 to 22):",
"Generate",
"Cancel",
previousSoundGenerationDuration,
"Set Sound Duration" );
},
"Describe the sound effect to generate:",
"Next",
"Cancel",
defaultInput,
"Generate Sound Effect" );
}
// [ContextMenuFor( typeof( SoundFile ), "Split sound", "content_cut" )]
public static async void SplitSound( Widget parent, SerializedProperty property )
{
var soundResource = property.GetValue<SoundFile>();
if ( soundResource == null || !soundResource.IsValid )
{
Log.Error( "No valid sound file selected" );
return;
}
var dialog = new Dialog();
dialog.Window.Title = "Split Sound";
dialog.Window.Size = new Vector2( 800, 400 );
dialog.Layout = Layout.Column();
var mainLayout = dialog.Layout.AddColumn();
mainLayout.Margin = 16f;
var loadingLabel = new Label( "Loading audio data..." );
mainLayout.Add( loadingLabel );
dialog.Show();
var samples = await soundResource.GetSamplesAsync();
if ( samples == null )
{
loadingLabel.Text = "Failed to load audio samples";
return;
}
loadingLabel.Destroy();
var spectogramWidget = new SpectogramWidget( soundResource );
mainLayout.Add( spectogramWidget, 1 );
var controlsLayout = mainLayout.AddRow();
var splitButton = new Button.Primary( "Split", "content_cut" );
controlsLayout.Add( splitButton );
splitButton.Clicked = () =>
{
var splitPoints = spectogramWidget.GetSplitPoints();
if ( splitPoints.Count < 1 ) return;
var listControlWidget = parent.GetAncestor<ListControlWidget>();
if ( listControlWidget == null )
{
Log.Error( "Could not find ListControlWidget parent" );
return;
}
try
{
var baseFileName = Path.GetFileNameWithoutExtension( soundResource.ResourcePath );
var outputDir = Path.Combine(
Project.Current.GetAssetsPath(),
"generated",
$"{baseFileName}_splits"
);
Directory.CreateDirectory( outputDir );
var newSoundFiles = new List<SoundFile>();
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)soundResource.Channels );
writer.Write( soundResource.Rate );
writer.Write( soundResource.Rate * soundResource.Channels * 2 );
writer.Write( (short)(soundResource.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 )
{
newSoundFiles.Add( soundFile );
}
}
}
if ( newSoundFiles.Count > 0 )
{
try
{
var collectionField = listControlWidget.GetType()
.GetFields( BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public )
.FirstOrDefault( f => typeof( SerializedCollection ).IsAssignableFrom( f.FieldType ) );
if ( collectionField == null )
{
return;
}
var collection = collectionField.GetValue( listControlWidget ) as SerializedCollection;
if ( collection == null )
{
return;
}
if ( property.TryGetAsObject( out var original ) )
{
foreach ( var soundFile in newSoundFiles )
{
collection.Add( soundFile );
}
collection.Remove( property );
}
}
catch ( Exception ex )
{
Log.Error( $"Error manipulating collection: {ex.Message}" );
Log.Error( $"Stack trace: {ex.StackTrace}" );
}
}
dialog.Close();
}
catch ( Exception ex )
{
Log.Error( $"Error splitting sound: {ex.Message}" );
}
};
}
}using Sandbox;
using System.Collections.Generic;
using Saandy.Tilemapper;
namespace Saandy.Editor.Tilemapper;
/// <summary>
/// 47-tile 3x3 blob autotile brush.
/// This is the "complex" brush: full 8-neighbor mask input, but reduced to the 47 useful blob shapes.
/// It distinguishes isolated 1x1 tiles, narrow caps/lines, edges, outer corners, and inner corners.
/// </summary>
public class TileBrush3x3Complex : TileBrush
{
public override string Name { get; protected set; } = "3x3 Complex Brush";
// Lookup uses this bit order:
// 1 2 4
// 8 X 16
// 32 64 128
//
// 255 = surrounded/full -> tile 0
// 0 = isolated -> tile 46
private static readonly ushort[] TileLookup =
{
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
42, 42, 32, 32, 42, 42, 32, 32, 37, 37, 27, 25, 37, 37, 27, 25,
35, 35, 19, 19, 35, 35, 18, 18, 23, 23, 15, 14, 23, 23, 13, 12,
42, 42, 32, 32, 42, 42, 32, 32, 36, 36, 26, 24, 36, 36, 26, 24,
35, 35, 19, 19, 35, 35, 18, 18, 21, 21, 7, 6, 21, 21, 5, 4,
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
46, 46, 44, 44, 46, 46, 44, 44, 45, 45, 39, 38, 45, 45, 39, 38,
43, 43, 41, 41, 43, 43, 40, 40, 33, 33, 31, 30, 33, 33, 29, 28,
42, 42, 32, 32, 42, 42, 32, 32, 37, 37, 27, 25, 37, 37, 27, 25,
34, 34, 17, 17, 34, 34, 16, 16, 22, 22, 11, 10, 22, 22, 9, 8,
42, 42, 32, 32, 42, 42, 32, 32, 36, 36, 26, 24, 36, 36, 26, 24,
34, 34, 17, 17, 34, 34, 16, 16, 20, 20, 3, 2, 20, 20, 1, 0
};
public override ushort GetSpriteIndexToSet( TileMap tilemap, TilesetResource tileset, int x, int y )
{
int bitmask = GetBitmask( tilemap, x, y );
return TileLookup[bitmask];
}
public override (bool IsVisible, bool UseCollider) GetDefaultTileFlags( ushort spriteIndex )
{
return (true, true);
}
private int GetBitmask( TileMap tilemap, int x, int y )
{
ushort centerTilesetId = tilemap.GetTilesetIdAt( x, y );
if ( centerTilesetId == 0 )
return 0;
int mask = 0;
// The lookup table/template is authored with visual Up as +Y in tilemap cell space.
// The old version used y - 1 for Up, which vertically mirrored every top/bottom
// edge and corner piece in the 3x3 complex brush.
// Top row / visual up.
if ( IsSameTileset( tilemap, centerTilesetId, x - 1, y + 1 ) )
mask |= 1; // UpLeft
if ( IsSameTileset( tilemap, centerTilesetId, x, y + 1 ) )
mask |= 2; // Up
if ( IsSameTileset( tilemap, centerTilesetId, x + 1, y + 1 ) )
mask |= 4; // UpRight
// Middle row.
if ( IsSameTileset( tilemap, centerTilesetId, x - 1, y ) )
mask |= 8; // Left
if ( IsSameTileset( tilemap, centerTilesetId, x + 1, y ) )
mask |= 16; // Right
// Bottom row / visual down.
if ( IsSameTileset( tilemap, centerTilesetId, x - 1, y - 1 ) )
mask |= 32; // DownLeft
if ( IsSameTileset( tilemap, centerTilesetId, x, y - 1 ) )
mask |= 64; // Down
if ( IsSameTileset( tilemap, centerTilesetId, x + 1, y - 1 ) )
mask |= 128; // DownRight
return mask;
}
private static bool IsSameTileset( TileMap tilemap, ushort centerTilesetId, int x, int y )
{
return tilemap.IsSameTilesetId( centerTilesetId, tilemap.GetTilesetIdAt( x, y ) );
}
protected override IEnumerable<Vector2Int> GetAffectedTiles( Vector2Int cell )
{
// Order does not affect the update result, but keep this listed visually top-to-bottom
// to match the bitmask comments above.
yield return new Vector2Int( cell.x - 1, cell.y + 1 );
yield return new Vector2Int( cell.x, cell.y + 1 );
yield return new Vector2Int( cell.x + 1, cell.y + 1 );
yield return new Vector2Int( cell.x - 1, cell.y );
yield return new Vector2Int( cell.x + 1, cell.y );
yield return new Vector2Int( cell.x - 1, cell.y - 1 );
yield return new Vector2Int( cell.x, cell.y - 1 );
yield return new Vector2Int( cell.x + 1, cell.y - 1 );
}
}
using Sandbox;
using System.Collections.Generic;
using Saandy.Tilemapper;
namespace Saandy.Editor.Tilemapper;
public class TileBrushManual : TileBrush
{
public override string Name { get; protected set; } = "Manual Brush";
public override ushort GetSpriteIndexToSet( TileMap tilemap, TilesetResource tileset, int x, int y )
{
if ( tileset == null )
return 0;
if ( tileset.Tiles == null || tileset.Tiles.Count == 0 )
return 0;
return (ushort)System.Math.Clamp( tileset.ManualTileIndex, 0, tileset.Tiles.Count - 1 );
}
public override (bool IsVisible, bool UseCollider) GetDefaultTileFlags( ushort spriteIndex )
{
return (true, true);
}
public override (TilesetResource Tileset, ushort TileId, bool IsVisible, bool UseCollider) GetTileToSet(
TileMap tilemap,
TilesetResource tileset,
TileMap.Tile existingTile,
int x,
int y,
bool isRefresh )
{
// If another autotile brush refreshes this manual tile as an affected neighbour,
// preserve the manually painted tile id and flags. Manual tiles do not derive
// their sprite from adjacency.
if ( isRefresh && !existingTile.IsEmpty )
return (tileset, existingTile.TileId, existingTile.IsVisible, existingTile.UseCollider);
return base.GetTileToSet( tilemap, tileset, existingTile, x, y, isRefresh );
}
protected override IEnumerable<Vector2Int> GetAffectedTiles( Vector2Int cell )
{
// Manual painting only changes the cells the user actually painted.
// No surrounding autotile refresh is needed.
yield break;
}
}
using Editor;
using Editor.Assets;
using Saandy.Tilemapper;
using Sandbox;
using static Editor.Inspectors.AssetInspector;
namespace Saandy.Editor.Tilemapper;
[CanEdit( "asset:tileset" )]
public sealed class TilesetResourceEditor : Widget, IAssetInspector
{
private Asset _asset;
private TilesetResource _tileset;
private ControlSheet _sheet;
private TilesetGlobalTagsWidget _globalTagsWidget;
private TilesetRectOrderWidget _rectWidget;
public TilesetResourceEditor( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 8;
MinimumHeight = 1000;
MinimumWidth = 600;
VerticalSizeMode = SizeMode.CanGrow;
HorizontalSizeMode = SizeMode.Flexible;
}
public void SetAssetPreview( AssetPreview preview )
{
}
public void SetAsset( Asset asset )
{
_asset = asset;
_tileset = asset?.LoadResource<TilesetResource>();
Rebuild();
}
[EditorEvent.Hotload]
private void OnHotload()
{
if ( _asset != null )
_tileset = _asset.LoadResource<TilesetResource>();
Rebuild();
}
private void Rebuild()
{
if ( Layout == null )
return;
Layout.Clear( true );
if ( _tileset == null )
{
Layout.Add( new Label( "No TilesetResource loaded.", this ) );
return;
}
var title = new Label( "Tileset Editor", this );
title.SetStyles( "font-size: 22px; font-weight: 700; margin-bottom: 8px;" );
Layout.Add( title );
var brushTemplate = TileBrushEditorTemplates.Get( _tileset.BrushType );
var brushInfo = new Label(
brushTemplate != null
? $"{brushTemplate.Title}: {brushTemplate.Description}"
: "No brush template selected.",
this
);
brushInfo.WordWrap = true;
brushInfo.SetStyles( "color: #ddd; margin-bottom: 6px;" );
Layout.Add( brushInfo );
var serializedObject = _tileset.GetSerialized();
_sheet = new ControlSheet();
_sheet.AddObject( serializedObject, prop =>
{
if ( prop.HasAttribute<HideAttribute>() )
return false;
if ( prop.Name == nameof( TilesetResource.Tiles ) )
return false;
if ( prop.Name == nameof( TilesetResource.GlobalTags ) )
return false;
return prop.HasAttribute<PropertyAttribute>();
} );
serializedObject.OnPropertyChanged += prop =>
{
if ( prop != null && prop.Name == nameof( TilesetResource.FilePath ) )
{
_tileset.ClearTextureCache();
_rectWidget?.Refresh();
MarkChanged();
return;
}
if ( prop != null && prop.Name == nameof( TilesetResource.BrushType ) )
{
MarkChanged();
Rebuild();
return;
}
MarkChanged();
};
Layout.Add( _sheet );
_globalTagsWidget = new TilesetGlobalTagsWidget( this, _tileset, MarkChanged )
{
MinimumWidth = 560
};
Layout.Add( _globalTagsWidget );
_rectWidget = new TilesetRectOrderWidget( _tileset, brushTemplate, MarkChanged )
{
MinimumWidth = 560
};
var buttonRow = Layout.Add( Layout.Row() );
buttonRow.Spacing = 6;
var generateButton = new Button( "Generate Grid Rects", "grid_on", this );
generateButton.Clicked = () =>
{
_tileset.GenerateTilesFromGrid();
MarkChanged();
_rectWidget?.Refresh();
_rectWidget?.LoadTemplateFromCurrentOrder();
};
buttonRow.Add( generateButton );
var clearButton = new Button( "Clear Tiles", "delete", this );
clearButton.Clicked = () =>
{
_tileset.ClearTiles();
MarkChanged();
_rectWidget?.Refresh();
_rectWidget?.ClearTemplate();
};
buttonRow.Add( clearButton );
var rectActionRow = Layout.Add( Layout.Row() );
rectActionRow.Spacing = 6;
var addRectButton = new Button( "Add Rect", "add", this );
addRectButton.Clicked = () => _rectWidget?.AddPendingRect();
rectActionRow.Add( addRectButton );
var deleteTileButton = new Button( "Delete Selected Rect", "delete", this );
deleteTileButton.Clicked = () => _rectWidget?.DeleteSelectedTile();
rectActionRow.Add( deleteTileButton );
if ( _rectWidget.HasTemplate )
{
var loadOrderButton = new Button( "Load Current Order", "refresh", this );
loadOrderButton.Clicked = () => _rectWidget?.LoadTemplateFromCurrentOrder();
rectActionRow.Add( loadOrderButton );
var applyOrderButton = new Button( "Apply Template Order", "done", this );
applyOrderButton.Clicked = () => _rectWidget?.ApplyTemplateOrder();
rectActionRow.Add( applyOrderButton );
}
Layout.Add( _rectWidget );
Layout.AddStretchCell();
}
private void MarkChanged()
{
if ( _tileset == null )
return;
_tileset.InternalUpdateTiles();
_tileset.StateHasChanged();
SaveAsset();
Update();
}
private void SaveAsset()
{
if ( _asset == null || _tileset == null )
return;
_asset.SaveToDisk( _tileset );
}
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Canvas;
// Same shape as ShaderGraph PreviewPanel: override PreFrame to advance the scene.
public class DesignerCanvas : SceneRenderingWidget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private readonly DesignerScene _designerScene;
public DesignerCanvas( Widget parent ) : base( parent )
{
Log.Info( $"{LogPrefix} DesignerCanvas ctor" );
_designerScene = new DesignerScene();
Scene = _designerScene.Scene;
Camera = _designerScene.Camera;
HorizontalSizeMode = SizeMode.Default | SizeMode.Expand;
VerticalSizeMode = SizeMode.Default | SizeMode.Expand;
// Without AcceptDrops, OnDragHover/OnDragDrop are never invoked.
AcceptDrops = true;
MouseTracking = true;
}
public DesignerScene DesignerScene => _designerScene;
// Assigned by DesignerWindow after construction (mirrors how _viewportFrame.Canvas is wired).
public OverlayController Overlay { get; set; }
public CanvasViewportFrame ViewportFrame { get; set; }
public event System.Action<Vector2, bool, Sandbox.KeyboardModifiers> CanvasClicked;
public event System.Action<Vector2, Sandbox.KeyboardModifiers> CanvasMoved;
public event System.Action<Vector2> CanvasReleased;
public event System.Action<Vector2> CanvasPanDragged; // delta, screen px
// Fires when the cursor leaves the canvas widget. Used to clear hover-pick state.
public event System.Action CanvasHoverEnded;
public event System.Action<ControlType, Vector2> RecordDropped;
// Mirrors RecordDropped but for saved palette templates.
public event System.Action<Grains.RazorDesigner.Templates.PaletteTemplate, Vector2> TemplateDropped;
private bool _middlePanning;
private Vector2 _lastPanScreen;
private const bool ProbeFrameCost = false;
private const int ProbeWindow = 120;
private readonly System.Diagnostics.Stopwatch _probeSw = new();
private double _probeTickMs;
private double _probeUpdateMs;
private int _probeFrames;
public override void OnDragHover( DragEvent ev )
{
base.OnDragHover( ev );
if ( ev.Data.Object is ControlType || ev.Data.Object is Grains.RazorDesigner.Templates.PaletteTemplate )
{
ev.Action = DropAction.Copy;
}
}
public override void OnDragDrop( DragEvent ev )
{
base.OnDragDrop( ev );
if ( ev.Data.Object is ControlType type )
{
Log.Info( $"{LogPrefix} DesignerCanvas drop: {type} at widget ({ev.LocalPosition.x:F0}, {ev.LocalPosition.y:F0})" );
RecordDropped?.Invoke( type, ev.LocalPosition );
}
else if ( ev.Data.Object is Grains.RazorDesigner.Templates.PaletteTemplate template )
{
Log.Info( $"{LogPrefix} DesignerCanvas drop: template \"{template.Name}\" at widget ({ev.LocalPosition.x:F0}, {ev.LocalPosition.y:F0})" );
TemplateDropped?.Invoke( template, ev.LocalPosition );
}
}
protected override void OnMousePress( MouseEvent e )
{
if ( e.MiddleMouseButton )
{
// Swallow entirely — don't let base (SceneRenderingWidget) see the middle drag.
_middlePanning = true;
_lastPanScreen = e.ScreenPosition;
e.Accepted = true;
return;
}
base.OnMousePress( e );
if ( e.LeftMouseButton || e.RightMouseButton )
{
var pos = e.LocalPosition;
Log.Info( $"{LogPrefix} DesignerCanvas click at widget ({pos.x:F0}, {pos.y:F0}) right={e.RightMouseButton}" );
CanvasClicked?.Invoke( pos, e.RightMouseButton, e.KeyboardModifiers );
}
}
protected override void OnMouseMove( MouseEvent e )
{
if ( _middlePanning )
{
var s = e.ScreenPosition;
CanvasPanDragged?.Invoke( s - _lastPanScreen );
_lastPanScreen = s;
e.Accepted = true;
return;
}
base.OnMouseMove( e );
CanvasMoved?.Invoke( e.LocalPosition, e.KeyboardModifiers );
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( _middlePanning )
{
_middlePanning = false;
e.Accepted = true;
return;
}
base.OnMouseReleased( e );
CanvasReleased?.Invoke( e.LocalPosition );
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
CanvasHoverEnded?.Invoke();
}
protected override void PreFrame()
{
base.PreFrame();
if ( !_designerScene.Scene.IsValid() )
return;
if ( ProbeFrameCost ) _probeSw.Restart();
using ( _designerScene.Scene.Push() )
{
// EditorTick (not GameTick): preview scene, no SceneNetworkUpdate / fixed physics.
_designerScene.Scene.EditorTick( RealTime.Now, RealTime.Delta );
}
if ( ProbeFrameCost ) { _probeTickMs += _probeSw.Elapsed.TotalMilliseconds; _probeSw.Restart(); }
// Layout in framebuffer space (Size * DpiScale); DesignerScene sets Scale = DpiScale so authoring stays in logical px.
_designerScene.Update( Size.x, Size.y, DpiScale );
Overlay?.Tick( _designerScene, DpiScale );
if ( ProbeFrameCost )
{
_probeUpdateMs += _probeSw.Elapsed.TotalMilliseconds;
if ( ++_probeFrames >= ProbeWindow )
{
Log.Info( $"{LogPrefix} probe: EditorTick avg {_probeTickMs / _probeFrames:F3}ms | Update avg {_probeUpdateMs / _probeFrames:F3}ms (over {_probeFrames} frames, canvas {Size.x:F0}x{Size.y:F0})" );
_probeTickMs = 0;
_probeUpdateMs = 0;
_probeFrames = 0;
}
}
}
protected override void OnKeyPress( KeyEvent e )
{
base.OnKeyPress( e );
if ( ViewportFrame is not { CanPanZoom: true } frame ) return;
var mods = e.KeyboardModifiers;
var ctrl = (mods & Sandbox.KeyboardModifiers.Ctrl) != 0;
var shift = (mods & Sandbox.KeyboardModifiers.Shift) != 0;
var alt = (mods & Sandbox.KeyboardModifiers.Alt) != 0;
if ( !ctrl || alt ) return; // every shortcut is Ctrl-based; Alt clears it.
switch ( e.Key )
{
case KeyCode.Num0:
if ( shift )
{
Log.Info( $"{LogPrefix} OnKeyPress Ctrl+Shift+0 → ZoomToSelection" );
ZoomToSelectionViaFrame( frame );
}
else
{
Log.Info( $"{LogPrefix} OnKeyPress Ctrl+0 → ApplyFit" );
frame.ApplyFit();
}
e.Accepted = true;
break;
case KeyCode.Num1:
if ( shift ) return;
Log.Info( $"{LogPrefix} OnKeyPress Ctrl+1 → ResetZoomOnly" );
frame.ResetZoomOnly();
e.Accepted = true;
break;
case KeyCode.Equal:
if ( shift ) return;
frame.ZoomBy( 1.25f );
e.Accepted = true;
break;
case KeyCode.Minus:
if ( shift ) return;
frame.ZoomBy( 1f / 1.25f );
e.Accepted = true;
break;
}
}
private void ZoomToSelectionViaFrame( CanvasViewportFrame frame )
{
var record = frame.Selection?.Selected;
var live = record?.LivePanel;
if ( live is null || !live.IsValid )
{
Log.Info( $"{LogPrefix} Ctrl+Shift+0: no valid selection" );
return;
}
frame.ApplyZoomToRect( live.Box.Rect, /* maxZoom */ 4f, /* padding */ 0.10f );
}
public override void OnDestroyed()
{
Log.Info( $"{LogPrefix} DesignerCanvas.OnDestroyed" );
base.OnDestroyed();
_designerScene.Dispose();
}
}
using System;
using System.Linq;
using System.Reflection;
using Sandbox;
using Sandbox.UI;
namespace Grains.RazorDesigner.Canvas;
public sealed class DesignerScene : IDisposable
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public static readonly Color ArtboardTintColor = new( 0.137f, 0.165f, 0.200f ); // ~#232a33
public static readonly Color WorkspaceClearColor = new( 0.055f, 0.067f, 0.078f ); // ~#0e1114
public static readonly Color HalvesLineColor = new( 1f, 1f, 1f, 0.22f ); // halves major-line tint
// Chrome label tints (workspace ScreenPanel runs at Scale=dpiScale so font sizes etc. are widget-px).
public static readonly Color ChromeMutedColor = new( 0.541f, 0.573f, 0.643f ); // #8a92a4
public static readonly Color ChromeBrightColor = new( 0.812f, 0.835f, 0.886f ); // #cfd5e2
public static readonly Color ChromePadBgColor = new( 0.055f, 0.067f, 0.078f, 0.95f );
public Scene Scene { get; }
public CameraComponent Camera { get; }
public ScreenPanel ScreenPanel { get; }
public Panel Root => ScreenPanel.GetPanel();
public ScreenPanel WorkspaceScreenPanel { get; }
public Panel WorkspaceRoot => WorkspaceScreenPanel.GetPanel();
// Workspace ScreenPanel was added in the pivot — needs its own isolation + reflection plumbing.
private bool _workspaceLayoutIsolated;
private bool _workspaceRootLogged;
public System.Func<string> FilenameSource { get; set; }
private Panel _backdrop;
private bool _backdropProbeLogged;
private Panel _halfVertical;
private Panel _halfHorizontal;
private bool _halvesProbeLogged;
private Panel _grid;
private float _gridLastStepCss = -1f;
private Vector2? _gridLastViewport;
private bool _gridProbeLogged;
private const float MinGridStepWidgetPx = 3f; // grid hides below this widget-px step (density clamp)
private static readonly Color GridLineColor = new( 1f, 1f, 1f, 0.045f );
public bool GridShow { get; set; }
public float GridStepCss { get; set; } = 8f;
public ArtboardFillMode ArtboardFill { get; set; } = ArtboardFillMode.Dark;
public Color ArtboardCustomColor { get; set; } = new( 0.137f, 0.165f, 0.200f );
private Panel _workspaceFill;
private Sandbox.UI.Label _filenameHeader;
private Sandbox.UI.Label _widthLabel;
private Panel _widthLabelPad;
private Sandbox.UI.Label _heightLabel;
private Panel _heightLabelPad;
private bool _workspaceChromeProbeLogged;
public Vector2? ViewportLogical { get; set; }
public PreviewTargetMode PreviewTarget { get; set; } = PreviewTargetMode.None;
public float Zoom { get; set; } = 1f;
public Vector2 PanOffsetWidgetPx { get; set; } = Vector2.Zero;
public float CurrentScale { get; private set; } = 1f;
public Rect RootBoundsFb { get; private set; }
private bool _rootLogged;
private bool _layoutIsolated;
private bool _reflectionLogged;
private MethodInfo _rootLayoutMethod;
private MethodInfo _rootBuildDescriptorsMethod;
private PropertyInfo _rootScaleProperty;
private PropertyInfo _rootPanelBoundsProperty;
public DesignerScene()
{
Log.Info( $"{LogPrefix} DesignerScene ctor (CreateEditorScene + EditorTick + two ScreenPanels)" );
Scene = Scene.CreateEditorScene();
Scene.Name = "Razor Designer";
using ( Scene.Push() )
{
var cameraGo = new GameObject( true, "camera" );
Camera = cameraGo.AddComponent<CameraComponent>();
Camera.BackgroundColor = WorkspaceClearColor;
Camera.IsMainCamera = false;
// Order matters: register Workspace first so it renders BEHIND the artboard.
var workspaceGo = new GameObject( true, "workspace-ui" );
WorkspaceScreenPanel = workspaceGo.AddComponent<ScreenPanel>();
WorkspaceScreenPanel.TargetCamera = Camera;
ForceLifecycle( WorkspaceScreenPanel );
var uiGo = new GameObject( true, "artboard-ui" );
ScreenPanel = uiGo.AddComponent<ScreenPanel>();
ScreenPanel.TargetCamera = Camera;
ForceLifecycle( ScreenPanel );
}
}
private static void ForceLifecycle( Component component )
{
var type = component.GetType();
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
var awake = type.GetMethod( "OnAwake", flags );
var enabled = type.GetMethod( "OnEnabled", flags );
try
{
awake?.Invoke( component, null );
enabled?.Invoke( component, null );
Log.Info( $"{LogPrefix} ForceLifecycle({type.Name}) ok awake={awake != null} enabled={enabled != null}" );
}
catch ( System.Exception ex )
{
Log.Error( $"{LogPrefix} ForceLifecycle({type.Name}) threw: {ex.GetType().Name}: {ex.Message}" );
throw;
}
}
// Call BEFORE Scene.Destroy(); component needs a valid scene to tear down.
private static void ForceTeardown( Component component )
{
if ( component is null ) return;
var type = component.GetType();
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
var disabled = type.GetMethod( "OnDisabled", flags );
var destroy = type.GetMethod( "OnDestroy", flags );
try
{
disabled?.Invoke( component, null );
destroy?.Invoke( component, null );
Log.Info( $"{LogPrefix} ForceTeardown({type.Name}) ok disabled={disabled != null} destroy={destroy != null}" );
}
catch ( System.Exception ex )
{
Log.Error( $"{LogPrefix} ForceTeardown({type.Name}) threw: {ex.GetType().Name}: {ex.Message}" );
}
}
private static bool TryIsolateRootFromMenuPump( Panel root )
{
if ( root is null ) return false;
try
{
var globalContextType = AppDomain.CurrentDomain.GetAssemblies()
.Select( a => SafeGetType( a, "Sandbox.Engine.GlobalContext" ) )
.FirstOrDefault( t => t is not null );
if ( globalContextType is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: GlobalContext type not found" );
return false;
}
var current = globalContextType
.GetProperty( "Current", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static )
?.GetValue( null );
if ( current is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: GlobalContext.Current is null" );
return false;
}
var uiSystem = current.GetType()
.GetProperty( "UISystem", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance )
?.GetValue( current );
if ( uiSystem is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: GlobalContext.UISystem is null" );
return false;
}
var removeRoot = uiSystem.GetType().GetMethod(
"RemoveRoot", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance );
if ( removeRoot is null )
{
Log.Warning( $"{LogPrefix} IsolateRoot: UISystem.RemoveRoot not found" );
return false;
}
removeRoot.Invoke( uiSystem, new object[] { root } );
Log.Info( $"{LogPrefix} IsolateRoot: removed from UISystem.RootPanels (engine layout pump will skip it)" );
return true;
}
catch ( Exception ex )
{
Log.Warning( $"{LogPrefix} IsolateRoot threw: {ex.GetType().Name}: {ex.Message}" );
return false;
}
}
private static Type SafeGetType( Assembly a, string fullName )
{
try { return a.GetType( fullName, throwOnError: false ); }
catch { return null; }
}
private void DriveLayout( Panel root, float widthPx, float heightPx, float dpiScale )
{
if ( widthPx < 1f || heightPx < 1f ) return;
if ( dpiScale < 0.01f ) dpiScale = 1.0f;
var pinned = PreviewTarget.IsPinned() ? PreviewTarget.PinnedScale() : 0f;
var (bounds, scale, _) = ComputeView( new Vector2( widthPx, heightPx ), dpiScale, ViewportLogical, pinned, Zoom, PanOffsetWidgetPx );
CurrentScale = scale;
RootBoundsFb = bounds;
var rootType = root.GetType();
EnsureReflectionResolved( rootType );
_rootPanelBoundsProperty?.SetValue( root, bounds );
_rootScaleProperty?.SetValue( root, scale );
try
{
_rootLayoutMethod?.Invoke( root, null );
_rootBuildDescriptorsMethod?.Invoke( root, new object[] { 1.0f } );
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} DriveLayout threw: {ex.GetType().Name}: {ex.Message}" );
}
}
public static (Rect Bounds, float Scale, Vector2 ClampedPanWidgetPx) ComputeView(
Vector2 widgetSize, float dpiScale, Vector2? viewportLogical, float pinnedScale, float zoom, Vector2 panOffsetWidgetPx )
{
if ( dpiScale < 0.01f ) dpiScale = 1f;
var fbW = widgetSize.x * dpiScale;
var fbH = widgetSize.y * dpiScale;
if ( fbW < 1f || fbH < 1f ) return ( new Rect( 0, 0, MathF.Max( fbW, 1f ), MathF.Max( fbH, 1f ) ), dpiScale, Vector2.Zero );
if ( !(viewportLogical is { } vp && vp.x >= 1f && vp.y >= 1f) )
return ( new Rect( 0, 0, fbW, fbH ), dpiScale, Vector2.Zero );
var baseScale = pinnedScale > 0.001f
? pinnedScale // grd-z71: 1 logical px == pinnedScale framebuffer px
: MathF.Min( fbW / vp.x, fbH / vp.y ); // scale-to-fit on the smaller axis
if ( zoom < 0.001f ) zoom = 1f;
var scale = baseScale * zoom;
if ( scale < 0.001f ) scale = dpiScale;
var panelW = vp.x * scale;
var panelH = vp.y * scale;
var panFb = panOffsetWidgetPx * dpiScale;
var centeredX = (fbW - panelW) * 0.5f;
var centeredY = (fbH - panelH) * 0.5f;
var keepX = MathF.Min( 80f * dpiScale, MathF.Min( panelW, fbW ) );
var keepY = MathF.Min( 80f * dpiScale, MathF.Min( panelH, fbH ) );
var x = Math.Clamp( centeredX + panFb.x, keepX - panelW, fbW - keepX );
var y = Math.Clamp( centeredY + panFb.y, keepY - panelH, fbH - keepY );
var bounds = new Rect( MathF.Floor( x ), MathF.Floor( y ), MathF.Floor( panelW ), MathF.Floor( panelH ) );
return ( bounds, scale, new Vector2( x - centeredX, y - centeredY ) / dpiScale );
}
private void EnsureReflectionResolved( Type rootType )
{
_rootLayoutMethod ??= ResolveMethod( rootType, "Layout", Type.EmptyTypes );
_rootBuildDescriptorsMethod ??= ResolveMethod( rootType, "BuildDescriptors", new[] { typeof( float ) } );
_rootScaleProperty ??= ResolveProperty( rootType, "Scale", typeof( float ) );
_rootPanelBoundsProperty ??= ResolveProperty( rootType, "PanelBounds", typeof( Rect ) );
if ( !_reflectionLogged )
{
Log.Info( $"{LogPrefix} DriveLayout reflection resolved: " +
$"Layout={_rootLayoutMethod is not null} " +
$"BuildDescriptors={_rootBuildDescriptorsMethod is not null} " +
$"Scale={_rootScaleProperty is not null} " +
$"PanelBounds={_rootPanelBoundsProperty is not null}" );
_reflectionLogged = true;
}
}
// Walk up: methods we want are on RootPanel base, not concrete GameRootPanel.
private static MethodInfo ResolveMethod( Type startType, string name, Type[] paramTypes )
{
for ( var t = startType; t is not null; t = t.BaseType )
{
var method = t.GetMethod( name,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
binder: null,
types: paramTypes,
modifiers: null );
if ( method is not null ) return method;
}
return null;
}
private static PropertyInfo ResolveProperty( Type startType, string name, Type expectedType )
{
for ( var t = startType; t is not null; t = t.BaseType )
{
var prop = t.GetProperty( name,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance );
if ( prop is not null && prop.PropertyType == expectedType )
return prop;
}
return null;
}
public void Update( float widthPx, float heightPx, float dpiScale )
{
var root = Root;
var workspaceRoot = WorkspaceRoot;
if ( !root.IsValid() ) return;
if ( !_layoutIsolated )
{
_layoutIsolated = TryIsolateRootFromMenuPump( root );
}
if ( workspaceRoot.IsValid() && !_workspaceLayoutIsolated )
{
_workspaceLayoutIsolated = TryIsolateRootFromMenuPump( workspaceRoot );
}
if ( !_rootLogged )
{
Log.Info( $"{LogPrefix} root valid (width={widthPx} height={heightPx} dpiScale={dpiScale})" );
_rootLogged = true;
}
if ( workspaceRoot.IsValid() && !_workspaceRootLogged )
{
Log.Info( $"{LogPrefix} workspace root valid (width={widthPx} height={heightPx} dpiScale={dpiScale})" );
_workspaceRootLogged = true;
}
EnsureBackdrop( root );
EnsureHalves( root );
if ( workspaceRoot.IsValid() ) EnsureWorkspaceChrome( workspaceRoot );
DriveLayout( root, widthPx, heightPx, dpiScale );
if ( workspaceRoot.IsValid() ) DriveWorkspaceLayout( workspaceRoot, widthPx, heightPx, dpiScale );
EnsureGrid( root, dpiScale );
// Position chrome AFTER DriveLayout so RootBoundsFb is current for this frame.
UpdateWorkspaceChromePositions( widthPx, heightPx, dpiScale );
}
private void EnsureBackdrop( Panel root )
{
if ( _backdrop is null || !_backdrop.IsValid || _backdrop.Parent != root )
{
_backdrop = new Panel( root );
_appliedFill = (ArtboardFillMode)(-1);
_backdrop.AddClass( "designer-backdrop" );
_backdrop.Style.Position = PositionMode.Absolute;
_backdrop.Style.Left = Length.Pixels( 0 );
_backdrop.Style.Top = Length.Pixels( 0 );
_backdrop.Style.Width = Length.Percent( 100 );
_backdrop.Style.Height = Length.Percent( 100 );
_backdrop.Style.ZIndex = -1000;
_backdrop.Style.PointerEvents = PointerEvents.None;
try { root.SetChildIndex( _backdrop, 0 ); }
catch ( Exception ex ) { Log.Warning( $"{LogPrefix} EnsureBackdrop SetChildIndex threw: {ex.GetType().Name}: {ex.Message}" ); }
if ( !_backdropProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureBackdrop: backdrop installed (fill={ArtboardFill.DisplayLabel()}, box-shadow DISABLED for triage, target=Root, zIndex=-1000)" );
_backdropProbeLogged = true;
}
}
ApplyArtboardFill( _backdrop );
}
private ArtboardFillMode _appliedFill = (ArtboardFillMode)(-1);
private Color _appliedCustom = Color.Black;
private void ApplyArtboardFill( Panel backdrop )
{
var mode = ArtboardFill;
var custom = ArtboardCustomColor;
if ( mode == _appliedFill && (mode != ArtboardFillMode.Custom || custom == _appliedCustom) )
return;
_appliedFill = mode;
_appliedCustom = custom;
if ( mode == ArtboardFillMode.Checker )
{
backdrop.Style.BackgroundColor = Color.White;
backdrop.Style.BackgroundImage = GetOrCreateCheckerTexture();
}
else
{
// Strip the texture if we're switching out of Checker, then apply the flat color.
backdrop.Style.BackgroundImage = null;
var color = mode == ArtboardFillMode.Custom ? custom : mode.PresetColor();
backdrop.Style.BackgroundColor = color;
}
Log.Info( $"{LogPrefix} ArtboardFill applied: {mode.DisplayLabel()}{(mode == ArtboardFillMode.Custom ? $" ({custom.Hex})" : "")}" );
}
private static Texture _checkerTexture;
private static Texture GetOrCreateCheckerTexture()
{
if ( _checkerTexture is not null && _checkerTexture.IsLoaded ) return _checkerTexture;
const int size = 32;
const int half = size / 2;
// Two-tone cool gray. Same dark family as the Dark preset, just split into two tones.
var darkRgb = new byte[] { 42, 45, 51 }; // ~#2a2d33
var lightRgb = new byte[] { 54, 58, 66 }; // ~#363a42
var data = new byte[size * size * 4];
for ( int y = 0; y < size; y++ )
for ( int x = 0; x < size; x++ )
{
var quadrant = (x < half) ^ (y < half);
var rgb = quadrant ? lightRgb : darkRgb;
int i = (y * size + x) * 4;
data[i + 0] = rgb[0];
data[i + 1] = rgb[1];
data[i + 2] = rgb[2];
data[i + 3] = 255;
}
_checkerTexture = Texture.Create( size, size, ImageFormat.RGBA8888 )
.WithName( "designer-artboard-checker" )
.WithData( data )
.Finish();
Log.Info( $"[Grains.RazorDesigner] GetOrCreateCheckerTexture: built {size}x{size} two-tone tile" );
return _checkerTexture;
}
private void EnsureHalves( Panel root )
{
EnsureHalf( ref _halfVertical, root, "designer-halves-v", isVertical: true );
EnsureHalf( ref _halfHorizontal, root, "designer-halves-h", isVertical: false );
var visible = GridShow;
var display = visible ? DisplayMode.Flex : DisplayMode.None;
if ( _halfVertical is not null && _halfVertical.IsValid ) _halfVertical.Style.Display = display;
if ( _halfHorizontal is not null && _halfHorizontal.IsValid ) _halfHorizontal.Style.Display = display;
if ( !_halvesProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureHalves: halves lines installed (zIndex=-999, alpha=0.22, visible={visible})" );
_halvesProbeLogged = true;
}
}
private void EnsureHalf( ref Panel slot, Panel root, string className, bool isVertical )
{
if ( slot is not null && slot.IsValid && slot.Parent == root )
return;
slot = new Panel( root );
slot.AddClass( className );
var s = slot.Style;
s.Position = PositionMode.Absolute;
s.BackgroundColor = HalvesLineColor;
s.ZIndex = -999;
s.PointerEvents = PointerEvents.None;
if ( isVertical )
{
s.Left = Length.Percent( 50 );
s.Top = Length.Pixels( 0 );
s.Width = Length.Pixels( 1 );
s.Height = Length.Percent( 100 );
}
else
{
s.Left = Length.Pixels( 0 );
s.Top = Length.Percent( 50 );
s.Width = Length.Percent( 100 );
s.Height = Length.Pixels( 1 );
}
}
private void EnsureGrid( Panel root, float dpiScale )
{
if ( dpiScale < 0.01f ) dpiScale = 1f;
// Density clamp: stepWidget = StepCss * (fb-per-css) / (fb-per-widget) = widget-per-css * StepCss.
var stepWidget = GridStepCss * CurrentScale / dpiScale;
var viewport = ViewportLogical;
var shouldShow = GridShow
&& GridStepCss > 0.001f
&& stepWidget >= MinGridStepWidgetPx
&& viewport is { } vpCheck && vpCheck.x >= 1f && vpCheck.y >= 1f;
if ( !shouldShow )
{
if ( _grid is { IsValid: true } ) _grid.Style.Set( "display", "none" );
return;
}
// Lazy-create container (self-healing — covers Repopulate's DeleteChildren wipe).
if ( _grid is null || !_grid.IsValid || _grid.Parent != root )
{
_grid = new Panel( root );
_grid.AddClass( "designer-grid" );
var s = _grid.Style;
s.Position = PositionMode.Absolute;
s.Left = Length.Pixels( 0 );
s.Top = Length.Pixels( 0 );
s.Width = Length.Percent( 100 );
s.Height = Length.Percent( 100 );
s.PointerEvents = PointerEvents.None;
s.ZIndex = -998; // above halves (-999), below user content (0)
_gridLastStepCss = -1f; // force line rebuild on next pass
_gridLastViewport = null;
if ( !_gridProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureGrid: grid container installed (zIndex=-998, thin-panel lines)" );
_gridProbeLogged = true;
}
}
// Rebuild line children only when step or viewport changes.
var vp = viewport.Value;
if ( MathF.Abs( GridStepCss - _gridLastStepCss ) > 0.01f || _gridLastViewport != vp )
{
_grid.DeleteChildren( immediate: true );
var step = GridStepCss;
var midX = vp.x * 0.5f;
var midY = vp.y * 0.5f;
int linesCreated = 0;
int halfStepsX = (int)MathF.Floor( midX / step );
float startX = midX - halfStepsX * step;
for ( float x = startX; x < vp.x - 0.5f; x += step )
{
if ( x < 0.5f ) continue;
if ( MathF.Abs( x - midX ) < 0.5f ) continue; // skip halves overlap
var line = new Panel( _grid );
var ls = line.Style;
ls.Position = PositionMode.Absolute;
ls.Left = Length.Pixels( x );
ls.Top = Length.Pixels( 0 );
ls.Width = Length.Pixels( 1 );
ls.Height = Length.Percent( 100 );
ls.BackgroundColor = GridLineColor;
ls.PointerEvents = PointerEvents.None;
linesCreated++;
}
int halfStepsY = (int)MathF.Floor( midY / step );
float startY = midY - halfStepsY * step;
for ( float y = startY; y < vp.y - 0.5f; y += step )
{
if ( y < 0.5f ) continue;
if ( MathF.Abs( y - midY ) < 0.5f ) continue; // skip halves overlap
var line = new Panel( _grid );
var ls = line.Style;
ls.Position = PositionMode.Absolute;
ls.Left = Length.Pixels( 0 );
ls.Top = Length.Pixels( y );
ls.Width = Length.Percent( 100 );
ls.Height = Length.Pixels( 1 );
ls.BackgroundColor = GridLineColor;
ls.PointerEvents = PointerEvents.None;
linesCreated++;
}
_gridLastStepCss = GridStepCss;
_gridLastViewport = vp;
Log.Info( $"{LogPrefix} EnsureGrid: rebuilt {linesCreated} line panels (step={step:F1} CSS, vp={vp.x:F0}x{vp.y:F0}, centered)" );
}
_grid.Style.Set( "display", "flex" );
}
private void DriveWorkspaceLayout( Panel root, float widthPx, float heightPx, float dpiScale )
{
if ( widthPx < 1f || heightPx < 1f ) return;
if ( dpiScale < 0.01f ) dpiScale = 1.0f;
var fbW = widthPx * dpiScale;
var fbH = heightPx * dpiScale;
var fullFb = new Rect( 0, 0, fbW, fbH );
var rootType = root.GetType();
EnsureReflectionResolved( rootType );
_rootPanelBoundsProperty?.SetValue( root, fullFb );
_rootScaleProperty?.SetValue( root, dpiScale );
try
{
_rootLayoutMethod?.Invoke( root, null );
_rootBuildDescriptorsMethod?.Invoke( root, new object[] { 1.0f } );
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} DriveWorkspaceLayout threw: {ex.GetType().Name}: {ex.Message}" );
}
}
private void EnsureWorkspaceChrome( Panel root )
{
EnsureWorkspaceFill( root );
EnsureFilenameHeader( root );
EnsureWidthLabel( root );
EnsureHeightLabel( root );
if ( !_workspaceChromeProbeLogged )
{
Log.Info( $"{LogPrefix} EnsureWorkspaceChrome: fill + header + width + height labels installed under WorkspaceRoot" );
_workspaceChromeProbeLogged = true;
}
}
private void EnsureWorkspaceFill( Panel root )
{
if ( _workspaceFill is not null && _workspaceFill.IsValid && _workspaceFill.Parent == root ) return;
_workspaceFill = new Panel( root );
_workspaceFill.AddClass( "designer-workspace-fill" );
var s = _workspaceFill.Style;
s.Position = PositionMode.Absolute;
s.Left = Length.Pixels( 0 );
s.Top = Length.Pixels( 0 );
s.Width = Length.Percent( 100 );
s.Height = Length.Percent( 100 );
s.BackgroundColor = WorkspaceClearColor;
s.ZIndex = -2000;
s.PointerEvents = PointerEvents.None;
s.SetBackgroundImage( GetOrCreateDotTexture() );
// Put it first in the sibling list so chrome labels paint on top.
try { root.SetChildIndex( _workspaceFill, 0 ); }
catch ( Exception ex ) { Log.Warning( $"{LogPrefix} EnsureWorkspaceFill SetChildIndex threw: {ex.GetType().Name}: {ex.Message}" ); }
}
private static Texture _dotTexture;
private static Texture GetOrCreateDotTexture()
{
if ( _dotTexture is not null && _dotTexture.IsLoaded ) return _dotTexture;
const int size = 28;
const int centre = size / 2;
var data = new byte[size * size * 4]; // RGBA8888
for ( int y = 0; y < size; y++ )
for ( int x = 0; x < size; x++ )
{
var dx = x - centre;
var dy = y - centre;
var d2 = dx * dx + dy * dy;
byte alpha = 0;
if ( d2 == 0 ) alpha = 18; // ~0.07 — exact centre
else if ( d2 == 1 ) alpha = 10; // ~0.04 — 4-neighbours
else if ( d2 == 2 ) alpha = 4; // ~0.015 — diagonals
int i = (y * size + x) * 4;
data[i + 0] = 255; // R
data[i + 1] = 255; // G
data[i + 2] = 255; // B
data[i + 3] = alpha;
}
_dotTexture = Texture.Create( size, size, ImageFormat.RGBA8888 )
.WithName( "designer-workspace-dot" )
.WithData( data )
.Finish();
Log.Info( $"[Grains.RazorDesigner] GetOrCreateDotTexture: built {size}x{size} dot tile (RGBA8888, {data.Length} bytes)" );
return _dotTexture;
}
private void EnsureFilenameHeader( Panel root )
{
if ( _filenameHeader is not null && _filenameHeader.IsValid && _filenameHeader.Parent == root ) return;
_filenameHeader = root.AddChild<Sandbox.UI.Label>();
_filenameHeader.AddClass( "designer-filename-header" );
var s = _filenameHeader.Style;
s.Position = PositionMode.Absolute;
s.FontColor = ChromeBrightColor; // Sandbox.UI Label can't easily mix colors in one widget; pick the brighter tint.
s.FontSize = 11f;
s.PointerEvents = PointerEvents.None;
s.Set( "white-space", "nowrap" );
s.Set( "display", "none" ); // hidden until UpdateWorkspaceChromePositions decides to show
}
private void EnsureWidthLabel( Panel root )
{
if ( _widthLabelPad is not null && _widthLabelPad.IsValid && _widthLabelPad.Parent == root ) return;
// Pad panel gives the label a subtle dark background pad (legibility over the dot pattern).
_widthLabelPad = new Panel( root );
_widthLabelPad.AddClass( "designer-dim-pad" );
var ps = _widthLabelPad.Style;
ps.Position = PositionMode.Absolute;
ps.BackgroundColor = ChromePadBgColor;
ps.PointerEvents = PointerEvents.None;
ps.Set( "padding", "1px 6px" );
ps.Set( "border-radius", "3px" );
ps.Set( "display", "none" );
_widthLabel = _widthLabelPad.AddChild<Sandbox.UI.Label>();
_widthLabel.AddClass( "designer-width-label" );
var ls = _widthLabel.Style;
ls.FontColor = ChromeMutedColor;
ls.FontSize = 10f;
ls.PointerEvents = PointerEvents.None;
ls.Set( "white-space", "nowrap" );
}
private void EnsureHeightLabel( Panel root )
{
if ( _heightLabelPad is not null && _heightLabelPad.IsValid && _heightLabelPad.Parent == root ) return;
_heightLabelPad = new Panel( root );
_heightLabelPad.AddClass( "designer-dim-pad" );
var ps = _heightLabelPad.Style;
ps.Position = PositionMode.Absolute;
ps.BackgroundColor = ChromePadBgColor;
ps.PointerEvents = PointerEvents.None;
ps.Set( "padding", "4px 2px" );
ps.Set( "border-radius", "3px" );
ps.Set( "display", "none" );
_heightLabel = _heightLabelPad.AddChild<Sandbox.UI.Label>();
_heightLabel.AddClass( "designer-height-label" );
var ls = _heightLabel.Style;
ls.FontColor = ChromeMutedColor;
ls.FontSize = 10f;
ls.PointerEvents = PointerEvents.None;
ls.Set( "white-space", "nowrap" );
ls.Set( "text-align", "center" );
}
private string _filenameLastText;
private string _widthLastText;
private string _heightLastText;
private bool _chromeVisibleLast;
private void UpdateWorkspaceChromePositions( float widthPx, float heightPx, float dpiScale )
{
if ( dpiScale < 0.01f ) dpiScale = 1f;
var shouldShow = ViewportLogical is { } vpCheck && vpCheck.x >= 1f && vpCheck.y >= 1f;
if ( !shouldShow )
{
if ( _chromeVisibleLast )
{
if ( _filenameHeader is { IsValid: true } ) _filenameHeader.Style.Set( "display", "none" );
if ( _widthLabelPad is { IsValid: true } ) _widthLabelPad.Style.Set( "display", "none" );
if ( _heightLabelPad is { IsValid: true } ) _heightLabelPad.Style.Set( "display", "none" );
_chromeVisibleLast = false;
}
return;
}
var vp = ViewportLogical.Value;
var artLeft = RootBoundsFb.Left / dpiScale;
var artTop = RootBoundsFb.Top / dpiScale;
var artRight = RootBoundsFb.Right / dpiScale;
var artBottom = RootBoundsFb.Bottom / dpiScale;
var artCenterX = (artLeft + artRight) * 0.5f;
var artCenterY = (artTop + artBottom) * 0.5f;
if ( _filenameHeader is { IsValid: true } )
{
var fname = FilenameSource?.Invoke() ?? "Untitled";
var newText = $"{fname} · {vp.x:F0}×{vp.y:F0}";
if ( _filenameLastText != newText )
{
_filenameHeader.Text = newText;
_filenameLastText = newText;
}
var fs = _filenameHeader.Style;
fs.Left = artLeft;
fs.Top = MathF.Max( 4f, artTop - 20f );
if ( !_chromeVisibleLast ) fs.Set( "display", "flex" );
}
// Width label — centered above the artboard's top edge.
if ( _widthLabelPad is { IsValid: true } && _widthLabel is { IsValid: true } )
{
var newText = $"{vp.x:F0}";
if ( _widthLastText != newText )
{
_widthLabel.Text = newText;
_widthLastText = newText;
}
var ps = _widthLabelPad.Style;
ps.Left = artCenterX;
ps.Top = MathF.Max( 4f, artTop - 18f );
ps.Set( "transform", "translateX(-50%)" );
if ( !_chromeVisibleLast ) ps.Set( "display", "flex" );
}
// Height label — centered on the artboard's right edge.
if ( _heightLabelPad is { IsValid: true } && _heightLabel is { IsValid: true } )
{
var newRaw = $"{vp.y:F0}";
if ( _heightLastText != newRaw )
{
_heightLabel.Text = string.Join( "\n", newRaw.ToCharArray() );
_heightLastText = newRaw;
}
var ps = _heightLabelPad.Style;
ps.Left = artRight + 8f;
ps.Top = artCenterY;
ps.Set( "transform", "translateY(-50%)" );
if ( !_chromeVisibleLast ) ps.Set( "display", "flex" );
}
_chromeVisibleLast = true;
}
public void Dispose()
{
Log.Info( $"{LogPrefix} DesignerScene.Dispose" );
ForceTeardown( ScreenPanel );
ForceTeardown( WorkspaceScreenPanel );
Scene?.Destroy();
}
}
using System;
using Editor;
using Grains.RazorDesigner.Selection;
using Sandbox;
namespace Grains.RazorDesigner.Canvas;
public sealed class ZoomHud : Widget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
// Visual tuning. All in widget px.
private const float Inset = 12f;
private const float HudHeight = 26f;
private const float CornerRadius = 5f;
private const int FontSizePx = 12;
private static readonly Color BgColor = new( 0.078f, 0.086f, 0.110f, 0.92f ); // ~ rgba(20,22,28,0.92)
private static readonly Color BorderColor = new( 0.227f, 0.255f, 0.314f, 1f ); // ~ #3a4150
private static readonly Color DividerColor = new( 0.165f, 0.180f, 0.220f, 1f ); // ~ #2a2e38
private static readonly Color TextColor = new( 0.878f, 0.894f, 0.918f, 1f ); // ~ #e0e3ea
private static readonly Color PctColor = new( 0.667f, 0.698f, 0.769f, 1f ); // ~ #aab2c4
private static readonly Color IconColor = new( 0.541f, 0.573f, 0.643f, 1f ); // ~ #8a92a4
private static readonly Color HoverOverlay = new( 1f, 1f, 1f, 0.05f );
private static readonly Color DisabledFg = new( 1f, 1f, 1f, 0.25f );
private readonly CanvasViewportFrame _frame;
private SelectionController CurrentSelection => _frame.Selection;
// Six segments, ordered left-to-right.
private enum Segment { ZoomOut, Percent, ZoomIn, Fit, Reset, Selection }
private static readonly string[] SegmentLabels = { "−", "100%", "+", "Fit", "1:1", "Sel" };
private static readonly string[] SegmentTooltips =
{
"Zoom out (Ctrl+−)",
"",
"Zoom in (Ctrl+=)",
"Fit content (Ctrl+0)",
"Reset to 100% (Ctrl+1)",
"Zoom to selection (Ctrl+Shift+0)",
};
private int _hoveredSegment = -1;
private float[] _segmentWidths;
private DesignerCanvas _canvas;
private int _lastGeometryHash = -1;
public ZoomHud( CanvasViewportFrame parent, DesignerCanvas canvas ) : base( parent )
{
_frame = parent ?? throw new ArgumentNullException( nameof( parent ) );
_canvas = canvas ?? throw new ArgumentNullException( nameof( canvas ) );
// Editor-side widgets do not draw at all by default; opt in.
TranslucentBackground = true;
NoSystemBackground = true;
WindowFlags = WindowFlags.FramelessWindowHint | WindowFlags.Tool;
MouseTracking = true;
// Subscribe to view changes — refresh on every push.
_frame.ZoomChanged += OnZoomChanged;
Log.Info( $"{LogPrefix} ZoomHud ctor (top-level frameless window)" );
}
public override void OnDestroyed()
{
_frame.ZoomChanged -= OnZoomChanged;
base.OnDestroyed();
}
[EditorEvent.Frame]
private void TrackCanvas()
{
if ( !_canvas.IsValid() ) { Visible = false; return; }
var shouldShow = _canvas.Visible && _frame.CanPanZoom;
if ( Visible != shouldShow ) { Visible = shouldShow; if ( !shouldShow ) return; }
if ( !shouldShow ) return;
EnsureWidthsMeasured();
var pref = PreferredSize;
var canvasScreen = _canvas.ScreenPosition;
var canvasSize = _canvas.Size;
var hash = HashCode.Combine( canvasScreen, canvasSize, pref );
if ( hash == _lastGeometryHash ) return;
_lastGeometryHash = hash;
Size = pref;
Position = new Vector2(
canvasScreen.x + canvasSize.x - pref.x - 12f,
canvasScreen.y + canvasSize.y - pref.y - 12f );
Update();
}
private void OnZoomChanged()
{
Update(); // schedule repaint; the % label re-renders from frame.Zoom.
}
public Vector2 PreferredSize
{
get
{
EnsureWidthsMeasured();
float total = 0f;
foreach ( var w in _segmentWidths ) total += w;
return new Vector2( total, HudHeight );
}
}
private void EnsureWidthsMeasured()
{
if ( _segmentWidths is not null ) return;
_segmentWidths = new float[ SegmentLabels.Length ];
Paint.SetDefaultFont( FontSizePx );
for ( var i = 0; i < SegmentLabels.Length; i++ )
{
var text = i == (int)Segment.Percent ? "1000%" : SegmentLabels[i];
var w = Paint.MeasureText( text ).x;
_segmentWidths[i] = MathF.Max( 28f, w + 18f ); // 9px padding each side
}
}
protected override void OnPaint()
{
EnsureWidthsMeasured();
var rect = LocalRect;
// Background + border.
Paint.SetBrush( BgColor );
Paint.SetPen( BorderColor );
Paint.DrawRect( rect, CornerRadius );
// Segments.
float x = rect.Left;
for ( var i = 0; i < SegmentLabels.Length; i++ )
{
var w = _segmentWidths[i];
var segRect = new Rect( x, rect.Top, w, rect.Height );
// Hover background. Don't highlight disabled Sel segment.
if ( _hoveredSegment == i && IsSegmentEnabled( (Segment)i ) )
{
Paint.ClearPen();
Paint.SetBrush( HoverOverlay );
Paint.DrawRect( segRect, 0f );
}
// Divider (between segments).
if ( i > 0 )
{
Paint.SetPen( DividerColor );
Paint.DrawLine( new Vector2( x, rect.Top + 4f ), new Vector2( x, rect.Bottom - 4f ) );
}
// Label.
var enabled = IsSegmentEnabled( (Segment)i );
Paint.SetDefaultFont( FontSizePx );
Paint.SetPen( ColorFor( (Segment)i, enabled ) );
var text = (Segment)i == Segment.Percent ? CurrentPercentText() : SegmentLabels[i];
Paint.DrawText( segRect, text, TextFlag.Center );
x += w;
}
}
private string CurrentPercentText()
{
var pct = (int)MathF.Round( _frame.Zoom * 100f );
return pct + "%";
}
private bool IsSegmentEnabled( Segment seg ) =>
seg != Segment.Selection || CurrentSelection?.Selected is not null;
private Color ColorFor( Segment seg, bool enabled )
{
if ( !enabled ) return DisabledFg;
return seg switch
{
Segment.ZoomOut or Segment.ZoomIn => IconColor,
Segment.Percent => PctColor,
_ => TextColor,
};
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var newHover = SegmentAt( e.LocalPosition );
if ( newHover != _hoveredSegment )
{
_hoveredSegment = newHover;
Update();
}
if ( _hoveredSegment >= 0 )
ToolTip = SegmentTooltips[_hoveredSegment];
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
if ( _hoveredSegment != -1 )
{
_hoveredSegment = -1;
Update();
}
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( !e.LeftMouseButton ) return;
var seg = SegmentAt( e.LocalPosition );
if ( seg < 0 ) return;
if ( !IsSegmentEnabled( (Segment)seg ) ) return;
DispatchSegment( (Segment)seg );
e.Accepted = true;
}
private void DispatchSegment( Segment seg )
{
switch ( seg )
{
case Segment.ZoomOut: _frame.ZoomBy( 1f / 1.25f ); break;
case Segment.ZoomIn: _frame.ZoomBy( 1.25f ); break;
case Segment.Percent: /* passive label — no-op */ break;
case Segment.Fit: _frame.ApplyFit(); break;
case Segment.Reset: _frame.ResetZoomOnly(); break;
case Segment.Selection: ZoomToSelection(); break;
}
}
private void ZoomToSelection()
{
var record = CurrentSelection?.Selected;
var live = record?.LivePanel;
if ( live is null || !live.IsValid )
{
Log.Info( $"{LogPrefix} ZoomHud Sel: no valid selection" );
return;
}
_frame.ApplyZoomToRect( live.Box.Rect, /* maxZoom */ 4f, /* padding */ 0.10f );
}
private int SegmentAt( Vector2 localPos )
{
EnsureWidthsMeasured();
if ( localPos.y < 0f || localPos.y > HudHeight ) return -1;
float x = 0f;
for ( var i = 0; i < _segmentWidths.Length; i++ )
{
x += _segmentWidths[i];
if ( localPos.x < x ) return i;
}
return -1;
}
}
using System.Collections.Generic;
using System.Linq;
using Grains.RazorDesigner.Wiring;
namespace Grains.RazorDesigner.CodeDialog;
public sealed class CodeDialogEditBuffer
{
public List<StateSymbol> States { get; }
public List<ParameterSymbol> Parameters { get; }
public IReadOnlyList<Symbol> OtherSymbols { get; }
public string Namespace { get; set; }
public string ClassName { get; set; }
public string BaseClass { get; set; }
public IReadOnlyList<string> Usings { get; set; }
public CodeDialogEditBuffer( WiringEnvelope source )
{
States = new List<StateSymbol>(
source.Symbols.OfType<StateSymbol>() );
Parameters = new List<ParameterSymbol>(
source.Symbols.OfType<ParameterSymbol>() );
OtherSymbols = source.Symbols
.Where( s => s is not StateSymbol and not ParameterSymbol )
.ToArray();
Namespace = source.Namespace;
ClassName = source.ClassName;
BaseClass = source.BaseClass;
Usings = source.Usings;
}
public WiringEnvelope ToEnvelope()
{
var symbols = new List<Symbol>( Parameters.Count + States.Count + OtherSymbols.Count );
symbols.AddRange( Parameters );
symbols.AddRange( States );
symbols.AddRange( OtherSymbols );
return new WiringEnvelope
{
Namespace = Namespace ?? "",
ClassName = ClassName ?? "",
BaseClass = string.IsNullOrEmpty( BaseClass ) ? "PanelComponent" : BaseClass,
Symbols = symbols,
Usings = Usings ?? System.Array.Empty<string>(),
};
}
}