UI/ContextMenu/GameObjectInspector.razor
@using Sandbox;
@using Sandbox.UI;
@attribute [InspectorEditor(null)]
@attribute [Order(100)]
@inherits Panel
@namespace Sandbox
@implements IInspectorEditor
<root>
<div class="body">
@if (Target == null || Target.Count == 0)
{
<div class="empty-state">#spawnmenu.inspect.click_to_inspect</div>
}
else
{
var totalMass = Target.SelectMany( go => go.GetComponentsInChildren<Rigidbody>() ).Sum( ResolveMass );
var health = Target.Select( go => go.GetComponent<Prop>() ).FirstOrDefault( p => p.IsValid() );
<div class="object-info">
@if ( totalMass > 0 )
{
<span>⚖️ @($"{totalMass:0.#} kg")</span>
}
@if ( health.IsValid() && health.Health != 0 )
{
<span>❤️ @($"{health.Health:0.#} HP")</span>
}
</div>
<ControlSheet Target="@Properties"></ControlSheet>
@if (Renderers.Count > 0)
{
@if ( MaterialGroups.Count > 1 )
{
var currentGroup = Renderers[0].MaterialGroup ?? MaterialGroups[0];
<div class="material-row">
<label>#spawnmenu.inspect.skin</label>
<div class="material-button" @onclick="@PickMaterialGroup">
<label>@currentGroup</label>
<label class="material-group-arrow">▾</label>
</div>
</div>
}
var accessor = Renderers[0].Materials;
@for (int i = 0; i < accessor.Count; i++)
{
var index = i;
var hasOverride = accessor.HasOverride(index);
var mat = hasOverride ? accessor.GetOverride(index) : accessor.GetOriginal(index);
var name = mat?.ResourceName ?? "Default";
<div class="material-row @(hasOverride ? "overridden" : "")">
<label>#spawnmenu.inspect.material</label><label> @(index + 1)</label>
<div class="material-button" @onclick=@(() => PickMaterial(index))>
<div class="material-preview" style="background-image: url( thumb:@(mat?.ResourcePath) )"></div>
<label>@name</label>
</div>
@if (hasOverride)
{
<div class="material-revert" @onclick=@(() => RevertMaterial(index))>x</div>
}
</div>
}
}
}
</div>
</root>
@code
{
public string Title => Target?.Count switch
{
null or < 2 => "📦 " + Game.Language.GetPhrase( "spawnmenu.inspect.object" ),
_ => "📦 " + Game.Language.GetPhrase( "spawnmenu.inspect.object" ) + $" (+{Target.Count - 1})"
};
public List<GameObject> Target { get; private set; }
public bool TrySetTarget(List<GameObject> selection)
{
var ids = selection.Select(x => x.Id);
if (!ids.SequenceEqual(Target?.Select(x => x.Id) ?? []))
{
Target = selection.Any() ? selection.ToList() : null;
RebuildFromTarget();
StateHasChanged();
}
// Hide the tab when something is selected but there's nothing to show
return Target == null || InspectorHasContent();
}
// Frozen rigidbodies report Mass = 0 from the live physics body, so fall back
// to PhysicalProperties.Mass and MassOverride before giving up.
static float ResolveMass( Rigidbody rb )
{
if ( !rb.IsValid() ) return 0f;
var mo = rb.GetComponent<PhysicalProperties>();
if ( mo.IsValid() && mo.Mass > 0f ) return mo.Mass;
if ( rb.MassOverride > 0f ) return rb.MassOverride;
return rb.Mass;
}
bool InspectorHasContent()
{
if ( Target == null ) return false;
if ( Properties.Count > 0 || Renderers.Count > 0 ) return true;
var totalMass = Target.SelectMany( go => go.GetComponentsInChildren<Rigidbody>() ).Sum( ResolveMass );
if ( totalMass > 0 ) return true;
var health = Target.Select( go => go.GetComponent<Prop>() ).FirstOrDefault( p => p.IsValid() );
if ( health.IsValid() && health.Health != 0 ) return true;
return false;
}
List<SerializedProperty> Properties = new();
List<ModelRenderer> Renderers = new();
List<string> MaterialGroups = new();
protected override int BuildHash()
{
var hc = new HashCode();
foreach ( var go in Target ?? [] )
{
hc.Add( go.Id );
hc.Add( ResolveMass( go.GetComponent<Rigidbody>() ) );
hc.Add( go.GetComponent<Prop>()?.Health ?? -1f );
hc.Add( go.GetComponent<ModelRenderer>()?.MaterialGroup );
}
return hc.ToHashCode();
}
protected override void OnParametersSet()
{
base.OnParametersSet();
RebuildFromTarget();
}
void RebuildFromTarget()
{
Properties = new();
Renderers = new();
MaterialGroups = new();
if (Target == null) return;
foreach (var c in Target.SelectMany(x => x.Components.GetAll()).Distinct().GroupBy(x => x is Collider ? typeof(Collider) : x.GetType()))
{
CollectProperties(c.ToArray());
}
}
bool HasEditableProperties(Type type, PropertyDescription[] properties)
{
if (type.IsAssignableTo(typeof(ModelRenderer))) return true;
if (type.IsAssignableTo(typeof(Collider))) return true;
foreach (var prop in properties)
{
if (prop.HasAttribute<ClientEditableAttribute>())
return true;
}
return false;
}
void CollectProperties(Component[] components)
{
var firstComponent = components.First();
var tl = TypeLibrary.GetType(firstComponent.GetType());
if (tl is null) return;
if (!HasEditableProperties(firstComponent.GetType(), tl.Properties)) return;
var so = new MultiSerializedObject();
so.OnPropertyChanged = PropertyChanged;
foreach (var component in components)
so.Add(TypeLibrary.GetSerializedObject(component));
so.Rebuild();
foreach (var prop in tl.Properties)
{
if (!prop.HasAttribute<ClientEditableAttribute>()) continue;
Properties.Add(so.GetProperty(prop.Name));
}
if (firstComponent is ModelRenderer mr)
{
Renderers.AddRange(components.OfType<ModelRenderer>());
var model = mr.Model;
if ( model is not null )
{
for ( int i = 0; i < model.MaterialGroupCount; i++ )
MaterialGroups.Add( model.GetMaterialGroupName( i ) );
}
var prop = mr.GetComponent<Prop>();
if (prop is not null)
{
var propso = TypeLibrary.GetSerializedObject(prop);
propso.OnPropertyChanged = PropertyChanged;
Properties.Add(propso.GetProperty(nameof(ModelRenderer.Tint)));
}
else
{
Properties.Add(so.GetProperty(nameof(ModelRenderer.Tint)));
}
Properties.Add(so.GetProperty(nameof(ModelRenderer.RenderType)));
}
if (firstComponent is Collider)
Properties.Add(so.GetProperty(nameof(Collider.Surface)));
}
void PropertyChanged(SerializedProperty prop)
{
foreach (var c in prop.Parent.Targets)
{
if (c is Component component)
GameManager.ChangeProperty(component, prop.Name, prop.GetValue<object>());
}
}
void PickMaterialGroup()
{
var menu = MenuPanel.Open( this );
var current = Renderers[0].MaterialGroup ?? MaterialGroups.FirstOrDefault();
foreach ( var group in MaterialGroups )
{
var g = group;
menu.AddOption( current == g ? "check" : "", g, () => SetMaterialGroup( g ) );
}
}
void SetMaterialGroup( string group )
{
foreach ( var renderer in Renderers )
GameManager.ChangeProperty( renderer, nameof( ModelRenderer.MaterialGroup ), group );
}
void PickMaterial(int index)
{
var accessor = Renderers[0].Materials;
var mat = accessor.HasOverride(index) ? accessor.GetOverride(index) : accessor.GetOriginal(index);
var popup = new ResourceSelectPopup();
popup.Extension = "material";
popup.CurrentValue = mat?.ResourcePath;
popup.AllowPackages = true;
popup.Parent = FindPopupPanel();
popup.OnSelectedFile = (path) => SetMaterialOverride(index, path);
}
void SetMaterialOverride(int index, string path)
{
foreach (var renderer in Renderers)
GameManager.ChangeMaterialOverride(renderer, index, path);
}
void RevertMaterial(int index)
{
foreach (var renderer in Renderers)
GameManager.ChangeMaterialOverride(renderer, index, null);
}
}