Game/ControlSystem/SignalSystem.cs
/// <summary>
/// The raw value of a signal delivery. You rarely need this — declare inputs as plain
/// <c>bool</c>, <c>float</c>, or parameterless methods and the system converts for you.
/// </summary>
public readonly record struct SignalEvent( float Analog, bool Down, bool Pressed, bool Released, Player Instigator );
/// <summary>
/// Makes a method or property wireable as an input. Works on: a parameterless method (fires
/// once when the signal turns on), a method taking a <c>bool</c> or <c>float</c>, or a writable
/// <c>bool</c>/<c>float</c> property.
/// On a writable property of a Component type it becomes a component input instead: linking
/// sets the property to the source component directly, and only components of that type
/// (marked <see cref="SignalOutputAttribute"/> on their class) can be linked to it.
/// The member's name is the wire's name — renaming it breaks saved contraptions.
/// </summary>
[AttributeUsage( AttributeTargets.Method | AttributeTargets.Property )]
public sealed class SignalInputAttribute : Attribute
{
/// <summary>
/// The stable port id. Defaults to the member name.
/// </summary>
public string Id { get; set; }
public string Name { get; set; }
public bool Default { get; set; }
}
/// <summary>
/// Makes a <see cref="SignalOutput"/> property wireable as an output.
/// The member's name is the wire's name — renaming it breaks saved contraptions.
/// On a class it exposes the component itself as a linkable reference instead: players can
/// wire it into any component input (<see cref="SignalInputAttribute"/> on a property of a
/// matching type) and the input receives the component directly. Wired into a boolean
/// input instead, it holds the input on for as long as the link exists.
/// </summary>
[AttributeUsage( AttributeTargets.Property | AttributeTargets.Class )]
public sealed class SignalOutputAttribute : Attribute
{
public string Name { get; set; }
public bool Default { get; set; }
}
/// <summary>
/// One wire: which component and which input it feeds into.
/// </summary>
public sealed class SignalConnection
{
[Property]
public Component Target { get; set; }
[Property]
public string Input { get; set; }
}
/// <summary>
/// An output port that players can wire to inputs on other entities. Call whichever
/// <c>Emit</c> overload matches what you have — the receiving end converts as needed.
/// State providers should emit every tick while active; this keeps continuous inputs push-driven.
/// Only does anything on the host.
///
/// Credit follows automatically: whoever last pressed or signaled this component is
/// passed along with the emit. Pass an instigator explicitly only to override that.
/// </summary>
public sealed class SignalOutput
{
[Property]
public List<SignalConnection> Connections { get; set; } = new();
private bool _lastDown;
private float _lastAnalog;
/// <summary>
/// Fire a one-shot pulse — "something happened once".
/// </summary>
public void Emit( Component source, Player instigator = null )
{
instigator ??= SignalSystem.InstigatorFor( source );
SignalSystem.Emit( this, new SignalEvent( 1f, true, true, false, instigator ) );
SignalSystem.Emit( this, new SignalEvent( 0f, false, false, true, instigator ) );
_lastDown = false;
_lastAnalog = 0f;
}
/// <summary>
/// Emit an on/off state.
/// </summary>
public void Emit( Component source, bool value, Player instigator = null )
{
// Active state is continuous; idle only needs to travel once on release.
if ( !value && !_lastDown ) return;
var pressed = value && !_lastDown;
var released = !value && _lastDown;
_lastDown = value;
_lastAnalog = value ? 1f : 0f;
instigator ??= SignalSystem.InstigatorFor( source );
SignalSystem.Emit( this, new SignalEvent( _lastAnalog, value, pressed, released, instigator ) );
}
/// <summary>
/// Emit a number. Values above 0.5 count as "on".
/// </summary>
public void Emit( Component source, float value, Player instigator = null )
{
// Keep non-zero analog signals continuous without ticking dormant graphs.
if ( value.AlmostEqual( 0f ) && _lastAnalog.AlmostEqual( 0f ) ) return;
var down = value > 0.5f;
var pressed = down && !_lastDown;
var released = !down && _lastDown;
_lastDown = down;
_lastAnalog = value;
instigator ??= SignalSystem.InstigatorFor( source );
SignalSystem.Emit( this, new SignalEvent( value, down, pressed, released, instigator ) );
}
}
internal readonly record struct SignalInputContext( PropertyDescription Property, SignalInputAttribute Attribute );
/// <summary>
/// What actually runs when a signal arrives at an input.
/// </summary>
internal readonly record struct SignalInputBinding(
MethodDescription Method,
PropertyDescription Property,
SignalInputKind Kind,
Action<Component, SignalEvent> Custom = null )
{
public void Invoke( Component component, SignalEvent value )
{
switch ( Kind )
{
case SignalInputKind.TriggerMethod:
if ( value.Pressed ) Method.Invoke( component, null );
break;
case SignalInputKind.EventMethod:
Method.Invoke( component, [value] );
break;
case SignalInputKind.BoolMethod:
Method.Invoke( component, [value.Down] );
break;
case SignalInputKind.FloatMethod:
Method.Invoke( component, [value.Analog] );
break;
case SignalInputKind.BoolProperty:
Property.SetValue( component, value.Down );
break;
case SignalInputKind.FloatProperty:
Property.SetValue( component, value.Analog );
break;
case SignalInputKind.ComponentProperty:
// Direct component link — nothing travels through it, the wire set the property.
break;
case SignalInputKind.Custom:
Custom?.Invoke( component, value );
break;
}
}
}
internal enum SignalInputKind
{
TriggerMethod,
EventMethod,
BoolMethod,
FloatMethod,
BoolProperty,
FloatProperty,
ComponentProperty,
Custom
}
/// <summary>
/// Lets a custom property type act as a signal input. Just implement this anywhere —
/// it's found automatically.
/// </summary>
internal interface ISignalAdapter
{
Type TargetType { get; }
bool TryBind( SignalInputContext context, out SignalInputBinding binding );
}
/// <summary>
/// The shared metadata exposed by both ends of a signal connection.
/// </summary>
internal abstract class SignalPortDescription
{
public Component Component { get; }
public string Id { get; }
public string Title { get; }
public string ComponentTitle { get; }
public string Description { get; }
public string Icon => string.IsNullOrWhiteSpace( _icon ) ? Kind : _icon;
public bool IsDefault { get; }
public int Order { get; }
/// <summary>
/// Component ports: the component type this port provides or accepts. Null for signal ports.
/// </summary>
public Type ComponentType { get; }
public string Kind => this is SignalOutputDescription ? "output" : "input";
private readonly string _icon;
protected SignalPortDescription( Component component, SignalPort port )
{
Component = component;
Id = port.Id;
Title = port.Title;
ComponentTitle = SignalSystem.GetComponentTitle( component );
Description = port.Description;
_icon = port.Icon;
IsDefault = port.IsDefault;
Order = port.Order;
ComponentType = port.ComponentType;
}
}
/// <summary>
/// An output port found on a component: its name and how to reach it.
/// </summary>
internal sealed class SignalOutputDescription : SignalPortDescription
{
public SignalOutput Port { get; }
internal SignalOutputDescription( Component component, SignalPort port, SignalOutput output ) : base( component, port )
{
Port = output;
}
}
/// <summary>
/// An input port found on a component: its name and how to reach it.
/// </summary>
internal sealed class SignalInputDescription : SignalPortDescription
{
internal SignalInputDescription( Component component, SignalPort port ) : base( component, port )
{
Binding = port.Binding;
}
internal SignalInputBinding Binding { get; }
}
/// <summary>
/// One input or output declared on a component type.
/// </summary>
internal readonly record struct SignalPort( string Id, string Title, string Description, string Icon, bool IsDefault, int Order )
{
/// <summary>
/// Outputs: the property holding the SignalOutput.
/// </summary>
public PropertyDescription Property { get; init; }
/// <summary>
/// Inputs: what to run when a signal arrives.
/// </summary>
public SignalInputBinding Binding { get; init; }
/// <summary>
/// Component ports: the component type this port provides or accepts.
/// </summary>
public Type ComponentType { get; init; }
}
/// <summary>
/// Delivers signals from outputs to the inputs they're wired to. Everything runs on the host;
/// clients just see the results through normal networking. TypeLibrary owns member caching and
/// hotload invalidation; this system resolves ports from its current descriptions when needed.
/// </summary>
internal sealed class SignalSystem : GameObjectSystem<SignalSystem>, IContextMenuEvent
{
/// <summary>
/// Port id of the synthetic output a class-level <see cref="SignalOutputAttribute"/> creates.
/// Can't collide with member names — C# identifiers can't contain '$'.
/// </summary>
internal const string ComponentOutputId = "$component";
private SignalPortDescription _contextSource;
// Who last pressed or signaled each component, so gates that re-emit later still
// pass the credit along without hand-carrying it. Pruned as components die.
private static readonly Dictionary<Component, Player> _instigators = new();
private readonly List<Component> _deadInstigators = new();
private RealTimeSince _sinceInstigatorPrune;
public SignalSystem( Scene scene ) : base( scene )
{
Listen( Stage.StartFixedUpdate, 0, PruneInstigators, "PruneSignalInstigators" );
}
void IContextMenuEvent.PopulateContextMenu( IContextMenuEvent.Event e )
{
if ( !e.Target.IsValid() ) return;
if ( _contextSource?.Component.IsValid() != true ) _contextSource = null;
if ( _contextSource is null )
{
AddContextPorts( e, e.Target );
return;
}
var ports = GetCompatiblePorts( e.Target, _contextSource ).ToArray();
if ( ports.Length > 0 && e.Target.Root != _contextSource.Component.GameObject.Root )
{
e.AddSubmenu( "link", "Link", submenu =>
{
foreach ( var port in ports )
submenu.AddOption( port.Title, port.Icon, () => CompleteContextLink( port ) );
} );
}
e.AddOption( "link_off", "Cancel Link", () => _contextSource = null );
}
private void AddContextPorts( IContextMenuEvent.Event e, GameObject target )
{
var ports = GetPorts( target ).ToArray();
if ( ports.Length == 0 ) return;
var defaults = ports.Where( port => port.IsDefault ).ToArray();
if ( ports.Length == 1 || defaults.Length == 1 )
{
var port = ports.Length == 1 ? ports[0] : defaults[0];
e.AddOption( "link", "Link", () => _contextSource = port );
return;
}
e.AddSubmenu( "link", "Link", submenu =>
{
foreach ( var port in ports )
submenu.AddOption( port.Title, port.Icon, () => _contextSource = port );
} );
}
private void CompleteContextLink( SignalPortDescription target )
{
CreateLink( _contextSource, target );
_contextSource = null;
}
/// <summary>
/// The player who last pressed or signaled this component, or null.
/// </summary>
internal static Player InstigatorFor( Component component )
{
if ( component.IsValid() && _instigators.TryGetValue( component, out var player ) && player.IsValid() )
return player;
return null;
}
private static void RememberInstigator( Component component, Player player )
{
if ( !component.IsValid() || !player.IsValid() ) return;
_instigators[component] = player;
}
private void PruneInstigators()
{
if ( Scene != Game.ActiveScene || !Networking.IsHost ) return;
if ( _sinceInstigatorPrune < 1f ) return;
_sinceInstigatorPrune = 0;
foreach ( var entry in _instigators )
{
if ( !entry.Key.IsValid() || !entry.Value.IsValid() )
_deadInstigators.Add( entry.Key );
}
foreach ( var component in _deadInstigators )
_instigators.Remove( component );
_deadInstigators.Clear();
}
public static IEnumerable<SignalOutputDescription> GetOutputs( Component component )
{
if ( !component.IsValid() ) yield break;
foreach ( var port in GetOutputPorts( component ) )
{
if ( port.ComponentType is not null )
yield return new SignalOutputDescription( component, port, null );
else if ( port.Property.GetValue( component ) is SignalOutput output )
yield return new SignalOutputDescription( component, port, output );
}
}
public static IEnumerable<SignalInputDescription> GetInputs( Component component )
{
if ( !component.IsValid() ) yield break;
foreach ( var port in GetInputPorts( component ) )
yield return new SignalInputDescription( component, port );
}
/// <summary>
/// Every output under this object's root. Unsorted — UI that lists ports uses <see cref="GetPorts"/>.
/// </summary>
public static IEnumerable<SignalOutputDescription> GetOutputs( GameObject gameObject )
{
if ( !gameObject.IsValid() ) return [];
return gameObject.Root.GetComponentsInChildren<Component>( true )
.Where( component => component.Enabled )
.SelectMany( GetOutputs );
}
public static IEnumerable<SignalPortDescription> GetPorts( GameObject gameObject )
{
if ( !gameObject.IsValid() ) return [];
return gameObject.Root.GetComponentsInChildren<Component>( true )
.Where( component => component.Enabled )
.SelectMany( component => GetOutputs( component ).Cast<SignalPortDescription>().Concat( GetInputs( component ) ) )
.OrderBy( port => port.Order )
.ThenByDescending( port => port.IsDefault )
.ThenBy( port => port.ComponentTitle, StringComparer.OrdinalIgnoreCase )
.ThenBy( port => port.Title, StringComparer.OrdinalIgnoreCase );
}
public static IEnumerable<SignalPortDescription> GetCompatiblePorts( GameObject gameObject, SignalPortDescription source )
{
return GetPorts( gameObject ).Where( port => AreCompatible( source, port ) );
}
internal static string GetComponentTitle( Component component )
{
var title = Game.TypeLibrary.GetType( component.GetType() )?.Title ?? component.GetType().Name;
return Game.Language.GetPhrase( title.TrimStart( '#' ) );
}
/// <summary>
/// Any signal output can wire to any signal input. Component outputs wire to component
/// inputs whose property type the component satisfies — or to boolean inputs, which they
/// hold on for as long as the link exists.
/// </summary>
public static bool AreCompatible( SignalOutputDescription output, SignalInputDescription input )
{
if ( output is null || input is null ) return false;
if ( output.ComponentType is null )
return input.ComponentType is null;
if ( input.ComponentType is not null )
return output.Component.GetType().IsAssignableTo( input.ComponentType );
return IsPresenceBindable( input.Binding );
}
private static bool IsPresenceBindable( SignalInputBinding binding )
{
return binding.Kind is SignalInputKind.BoolProperty or SignalInputKind.BoolMethod;
}
public static bool AreCompatible( SignalPortDescription first, SignalPortDescription second )
{
return TryResolve( first, second, out _, out _ );
}
public static bool IsConnected( SignalOutputDescription output, SignalInputDescription input )
{
if ( output is null || input is null ) return false;
if ( input.ComponentType is not null )
return input.Binding.Property?.GetValue( input.Component ) as Component == output.Component;
return HasConnection( output.Port, input.Component, input.Id );
}
public static void SetConnected( SignalOutputDescription output, SignalInputDescription input, bool connected )
{
if ( output is null || input is null ) return;
SetConnectionRpc( output.Component, output.Id, input.Component, input.Id, connected );
}
public static void SetConnected( SignalPortDescription first, SignalPortDescription second, bool connected )
{
if ( TryResolve( first, second, out var output, out var input ) )
SetConnected( output, input, connected );
}
/// <summary>
/// Create a logical object link and connect the selected signal ports in one host operation.
/// </summary>
public static void CreateLink( SignalOutputDescription output, SignalInputDescription input )
{
if ( output is null || input is null ) return;
CreateLinkRpc( output.Component, output.Id, input.Component, input.Id );
}
public static void CreateLink( SignalPortDescription first, SignalPortDescription second )
{
if ( TryResolve( first, second, out var output, out var input ) )
CreateLink( output, input );
}
private static bool TryResolve( SignalPortDescription first, SignalPortDescription second, out SignalOutputDescription output, out SignalInputDescription input )
{
output = first as SignalOutputDescription ?? second as SignalOutputDescription;
input = first as SignalInputDescription ?? second as SignalInputDescription;
return AreCompatible( output, input );
}
[Rpc.Host]
private static void CreateLinkRpc( Component source, string outputId, Component target, string inputId )
{
if ( !source.IsValid() || !target.IsValid() ) return;
if ( !source.GameObject.HasAccess( Rpc.Caller ) || !target.GameObject.HasAccess( Rpc.Caller ) ) return;
if ( !TryFindOutput( source, outputId, out var output ) || !TryFindInput( target, inputId, out var input ) ) return;
if ( IsConnectedInternal( source, output, target, input ) ) return;
var sourceRoot = source.GameObject.Root;
var targetRoot = target.GameObject.Root;
if ( !sourceRoot.IsValid() || !targetRoot.IsValid() || sourceRoot == targetRoot ) return;
// A component input holds one reference — relinking replaces the old wire entirely.
if ( input.ComponentType is not null )
DestroyStampedWires( target, input.Id );
var player = Player.FindForConnection( Rpc.Caller );
var links = ManualLink.CreatePair( sourceRoot, targetRoot );
if ( !SetConnectedInternal( source, output, target, input, true, player ) )
{
links[0].Destroy();
return;
}
links[0].GetComponent<ManualLink>()?.SetWire( source, outputId, target, inputId );
SendConnectionNotice( Rpc.Caller, source, output, target, input, true );
if ( player.IsValid() )
{
var undo = player.Undo.Create();
undo.Name = "Link";
undo.Add( links[0] );
}
}
[Rpc.Host]
private static void SetConnectionRpc( Component source, string outputId, Component target, string inputId, bool connected )
{
if ( !source.IsValid() || !target.IsValid() ) return;
// Prop protection, and wires can't leave the contraption.
if ( !source.GameObject.HasAccess( Rpc.Caller ) ) return;
var linked = new LinkedGameObjectBuilder();
linked.AddConnected( source.GameObject );
if ( !linked.Objects.Contains( target.GameObject.Root ) ) return;
if ( !TryFindOutput( source, outputId, out var output ) || !TryFindInput( target, inputId, out var input ) ) return;
// A component input holds one reference — relinking replaces the old wire entirely.
if ( connected && input.ComponentType is not null )
DestroyStampedWires( target, input.Id );
if ( !SetConnectedInternal( source, output, target, input, connected, Player.FindForConnection( Rpc.Caller ) ) ) return;
if ( connected )
StampWire( source, outputId, target, inputId );
SendConnectionNotice( Rpc.Caller, source, output, target, input, connected );
}
/// <summary>
/// Record the wire on the ManualLink pair that carries it, so destroying the link
/// (undo, the wire tool's unlink) also disconnects the wire. Wires between objects
/// joined some other way (welds, same root) have no pair — that's fine.
/// </summary>
private static void StampWire( Component source, string outputId, Component target, string inputId )
{
var targetRoot = target.GameObject.Root;
foreach ( var link in source.GameObject.Root.GetComponentsInChildren<ManualLink>( true ) )
{
if ( link.HasWire || link.Body?.Root != targetRoot ) continue;
link.SetWire( source, outputId, target, inputId );
return;
}
}
/// <summary>
/// Disconnect the wire a dying ManualLink was carrying.
/// </summary>
internal static void DisconnectWire( ManualLink link )
{
if ( !Networking.IsHost || !link.HasWire ) return;
if ( !link.SignalTarget.IsValid() ) return;
// Component wires can let go of a dead source; signal wires need a live one to prune.
if ( !link.IsComponentWire && !link.SignalSource.IsValid() ) return;
SetConnectedInternal( link.SignalSource, link.SignalOutputId, link.SignalTarget, link.SignalInputId, false, dying: link );
}
private static void SendConnectionNotice( Connection caller, Component source, SignalPort output, Component target, SignalPort input, bool connected )
{
var sourceName = GetComponentTitle( source );
var targetName = GetComponentTitle( target );
var action = connected ? "Linked" : "Unlinked";
var preposition = connected ? "to" : "from";
Sandbox.UI.Notices.SendNotice(
caller,
connected ? "link" : "link_off",
connected ? Color.Green : Color.Yellow,
$"{action} {sourceName}: {output.Title} {preposition} {targetName}: {input.Title}",
3f );
}
private static bool SetConnectedInternal( Component source, string outputId, Component target, string inputId, bool connected, Player instigator = null, ManualLink dying = null )
{
if ( !TryFindOutput( source, outputId, out var output ) ) return false;
if ( !TryFindInput( target, inputId, out var input ) ) return false;
return SetConnectedInternal( source, output, target, input, connected, instigator, dying );
}
private static bool SetConnectedInternal( Component source, SignalPort output, Component target, SignalPort input, bool connected, Player instigator = null, ManualLink dying = null )
{
if ( output.ComponentType is not null || input.ComponentType is not null )
return SetComponentLinkInternal( source, output, target, input, connected, instigator, dying );
if ( output.Property.GetValue( source ) is not SignalOutput port ) return false;
var connections = port.Connections ??= new();
var wasConnected = HasConnection( port, target, input.Id );
connections.RemoveAll( connection => connection is null
|| !connection.Target.IsValid()
|| (connection.Target == target && connection.Input == input.Id) );
if ( connected )
connections.Add( new SignalConnection { Target = target, Input = input.Id } );
else if ( wasConnected )
Deliver( target, input, new SignalEvent( 0f, false, false, true, null ) ); // let go of anything held on
source.GameObject.Network?.Refresh();
return true;
}
private static bool HasConnection( SignalOutput port, Component target, string inputId )
{
return port?.Connections?.Any( connection => connection?.Target == target && connection.Input == inputId ) == true;
}
/// <summary>
/// Component links set the input property to the source component directly — there is
/// no connection list and nothing ever travels; the reference is the whole wire.
/// Into a boolean input, the reference implicitly reads as a bool instead: on while
/// anything is wired in, off when the last wire dies.
/// </summary>
private static bool SetComponentLinkInternal( Component source, SignalPort output, Component target, SignalPort input, bool connected, Player instigator, ManualLink dying )
{
if ( output.ComponentType is null || !target.IsValid() ) return false;
if ( input.ComponentType is null )
{
if ( !IsPresenceBindable( input.Binding ) ) return false;
if ( connected )
{
Deliver( target, input, new SignalEvent( 1f, true, true, false, instigator ) );
}
else
{
// Presence wires only die with their ManualLink pair — refuse anything else,
// or the input would stay held with no wire left to release it.
if ( dying is null ) return false;
if ( FindPresenceWire( target, input.Id, exclude: dying ) ) return true;
Deliver( target, input, new SignalEvent( 0f, false, false, true, instigator ) );
}
target.GameObject.Network?.Refresh();
return true;
}
if ( input.Binding.Property is not { } property ) return false;
if ( connected )
{
if ( !source.IsValid() || !source.GetType().IsAssignableTo( input.ComponentType ) ) return false;
property.SetValue( target, source );
}
else
{
// Only let go if we still hold this source (or a dead one) — a relink may have replaced it.
if ( property.GetValue( target ) is not Component current ) return false;
if ( current != source && current.IsValid() ) return false;
property.SetValue( target, null );
}
// The mutated state lives on the target for component links.
target.GameObject.Network?.Refresh();
return true;
}
/// <summary>
/// Is there a stamped component wire into this input? Optionally from one specific
/// source, optionally ignoring a link that's currently being destroyed.
/// </summary>
private static bool FindPresenceWire( Component target, string inputId, Component source = null, ManualLink exclude = null )
{
foreach ( var link in target.GameObject.Root.GetComponentsInChildren<ManualLink>( true ) )
{
if ( link == exclude || !link.HasWire || !link.IsComponentWire ) continue;
if ( link.SignalTarget != target || !link.SignalSource.IsValid() ) continue;
if ( source is not null && link.SignalSource != source ) continue;
if ( !string.Equals( link.SignalInputId, inputId, StringComparison.OrdinalIgnoreCase ) ) continue;
return true;
}
return false;
}
private static bool IsConnectedInternal( Component source, SignalPort output, Component target, SignalPort input )
{
if ( input.ComponentType is not null )
return input.Binding.Property?.GetValue( target ) as Component == source;
if ( output.ComponentType is not null )
return FindPresenceWire( target, input.Id, source );
return output.Property?.GetValue( source ) is SignalOutput port && HasConnection( port, target, input.Id );
}
/// <summary>
/// Destroy the stamped ManualLink pairs carrying wires into this input.
/// </summary>
private static void DestroyStampedWires( Component target, string inputId )
{
foreach ( var link in target.GameObject.Root.GetComponentsInChildren<ManualLink>( true ).ToArray() )
{
if ( !link.IsValid() || link.SignalTarget != target ) continue;
if ( !string.Equals( link.SignalInputId, inputId, StringComparison.OrdinalIgnoreCase ) ) continue;
link.GameObject.Destroy();
}
}
internal static void Emit( SignalOutput port, SignalEvent value )
{
if ( !Networking.IsHost || port?.Connections is null ) return;
foreach ( var connection in port.Connections )
{
if ( connection?.Target.IsValid() != true ) continue;
if ( !TryFindInput( connection.Target, connection.Input, out var input ) ) continue;
Deliver( connection.Target, input, value );
}
}
/// <summary>
/// Every output on this object's contraption (anything joined by welds, joints or links).
/// Tools and UI should use this so they all agree on what's wireable.
/// </summary>
public static IEnumerable<SignalOutputDescription> GetContraptionOutputs( GameObject gameObject )
{
return GetContraptionComponents( gameObject ).SelectMany( GetOutputs );
}
/// <summary>
/// Every input on this object's contraption.
/// </summary>
public static IEnumerable<SignalInputDescription> GetContraptionInputs( GameObject gameObject )
{
return GetContraptionComponents( gameObject ).SelectMany( GetInputs );
}
private static IEnumerable<Component> GetContraptionComponents( GameObject gameObject )
{
if ( !gameObject.IsValid() ) yield break;
var linked = new LinkedGameObjectBuilder();
linked.AddConnected( gameObject );
foreach ( var root in linked.Objects )
{
foreach ( var component in root.GetComponentsInChildren<Component>( true ) )
{
if ( component.IsValid() ) yield return component;
}
}
}
private static void Deliver( Component component, SignalPort input, SignalEvent value )
{
RememberInstigator( component, value.Instigator );
try
{
using var scope = ControlContext.Push( value.Instigator );
input.Binding.Invoke( component, value );
}
catch ( Exception exception )
{
Log.Warning( $"Signal input {component}.{input.Id} failed: {exception.Message}" );
}
}
private static IEnumerable<SignalPort> GetOutputPorts( Component component )
{
var type = Game.TypeLibrary.GetType( component.GetType() );
if ( type is null ) yield break;
if ( type.GetAttribute<SignalOutputAttribute>() is { } classAttribute )
yield return CreateComponentOutput( type, classAttribute );
var ids = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var property in type.Properties )
{
var attribute = property.GetCustomAttribute<SignalOutputAttribute>();
if ( attribute is null ) continue;
if ( !TryCreateOutput( type, property, attribute, out var port ) ) continue;
if ( !ids.Add( port.Id ) )
{
Log.Warning( $"Signal output '{port.Id}' is declared twice on {type.FullName}; the duplicate was ignored." );
continue;
}
yield return port;
}
}
private static IEnumerable<SignalPort> GetInputPorts( Component component )
{
var type = Game.TypeLibrary.GetType( component.GetType() );
if ( type is null ) yield break;
var ids = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var method in type.Methods )
{
var attribute = method.GetCustomAttribute<SignalInputAttribute>();
if ( attribute is null ) continue;
if ( !TryCreateInput( type, method, attribute, out var port ) ) continue;
if ( TryAddInput( type, ids, port ) ) yield return port;
}
foreach ( var property in type.Properties )
{
var attribute = property.GetCustomAttribute<SignalInputAttribute>();
if ( attribute is null ) continue;
if ( !TryCreateInput( type, property, attribute, out var port ) ) continue;
if ( TryAddInput( type, ids, port ) ) yield return port;
}
}
private static bool TryAddInput( TypeDescription type, HashSet<string> ids, SignalPort port )
{
if ( ids.Add( port.Id ) ) return true;
Log.Warning( $"Signal input '{port.Id}' is declared twice on {type.FullName}; the duplicate was ignored." );
return false;
}
private static bool TryFindOutput( Component component, string id, out SignalPort result )
{
result = default;
var found = false;
var type = Game.TypeLibrary.GetType( component.GetType() );
if ( type is null ) return false;
if ( string.Equals( id, ComponentOutputId, StringComparison.OrdinalIgnoreCase ) )
{
if ( type.GetAttribute<SignalOutputAttribute>() is not { } classAttribute ) return false;
result = CreateComponentOutput( type, classAttribute );
return true;
}
foreach ( var property in type.Properties )
{
var attribute = property.GetCustomAttribute<SignalOutputAttribute>();
if ( attribute is null || !string.Equals( property.Name, id, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( !TryCreateOutput( type, property, attribute, out var port ) ) continue;
if ( found )
{
Log.Warning( $"Signal output '{id}' is declared twice on {type.FullName}; refusing to choose one." );
return false;
}
result = port;
found = true;
}
return found;
}
private static bool TryFindInput( Component component, string id, out SignalPort result )
{
result = default;
var found = false;
var type = Game.TypeLibrary.GetType( component.GetType() );
if ( type is null ) return false;
foreach ( var method in type.Methods )
{
var attribute = method.GetCustomAttribute<SignalInputAttribute>();
var portId = attribute?.Id ?? method.Name;
if ( attribute is null || !string.Equals( portId, id, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( !TryCreateInput( type, method, attribute, out var port ) ) continue;
if ( found ) return WarnDuplicateInput( type, id, out result );
result = port;
found = true;
}
foreach ( var property in type.Properties )
{
var attribute = property.GetCustomAttribute<SignalInputAttribute>();
var portId = attribute?.Id ?? property.Name;
if ( attribute is null || !string.Equals( portId, id, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( !TryCreateInput( type, property, attribute, out var port ) ) continue;
if ( found ) return WarnDuplicateInput( type, id, out result );
result = port;
found = true;
}
return found;
}
private static bool WarnDuplicateInput( TypeDescription type, string id, out SignalPort result )
{
result = default;
Log.Warning( $"Signal input '{id}' is declared twice on {type.FullName}; refusing to choose one." );
return false;
}
private static SignalPort CreateComponentOutput( TypeDescription type, SignalOutputAttribute attribute )
{
// The component itself is the port; it lists ahead of its signal ports.
return new SignalPort(
ComponentOutputId,
attribute.Name ?? type.Title ?? type.Name,
type.Description,
type.Icon,
attribute.Default,
-1 ) { ComponentType = type.TargetType };
}
private static bool TryCreateOutput( TypeDescription type, PropertyDescription property, SignalOutputAttribute attribute, out SignalPort port )
{
port = default;
if ( property.PropertyType != typeof( SignalOutput ) || !property.CanRead )
{
Log.Warning( $"Signal output {type.FullName}.{property.Name} must be a readable SignalOutput property." );
return false;
}
port = new SignalPort(
property.Name,
attribute.Name ?? property.Title ?? property.Name,
property.Description,
property.Icon,
attribute.Default,
property.Order ) { Property = property };
return true;
}
private static bool TryCreateInput( TypeDescription type, MethodDescription method, SignalInputAttribute attribute, out SignalPort port )
{
port = default;
if ( method.ReturnType != typeof( void ) || !TryBindMethod( method, out var binding ) )
{
Log.Warning( $"Signal input {type.FullName}.{method.Name} has an unsupported signature. Use no parameters, or one bool / float / SignalEvent parameter, and return void." );
return false;
}
var id = attribute.Id ?? method.Name;
var declaredPort = type.Properties.FirstOrDefault( property => string.Equals( property.Name, id, StringComparison.OrdinalIgnoreCase ) );
var description = !string.IsNullOrWhiteSpace( method.Description ) ? method.Description : declaredPort?.Description;
port = new SignalPort(
id,
attribute.Name ?? declaredPort?.Title ?? id,
description,
method.Icon ?? declaredPort?.Icon,
attribute.Default,
method.Order ) { Binding = binding };
return true;
}
private static bool TryCreateInput( TypeDescription type, PropertyDescription property, SignalInputAttribute attribute, out SignalPort port )
{
port = default;
if ( !TryBindProperty( property, attribute, out var binding ) )
{
Log.Warning( $"Signal input {type.FullName}.{property.Name} has an unsupported type. Use bool, float, a Component type, or register an ISignalAdapter." );
return false;
}
var id = attribute.Id ?? property.Name;
port = new SignalPort(
id,
attribute.Name ?? property.Title ?? id,
property.Description,
property.Icon,
attribute.Default,
property.Order )
{
Binding = binding,
ComponentType = binding.Kind == SignalInputKind.ComponentProperty ? property.PropertyType : null
};
return true;
}
private static bool TryBindMethod( MethodDescription method, out SignalInputBinding binding )
{
binding = default;
var parameters = method.Parameters;
if ( parameters.Length == 0 )
{
binding = new SignalInputBinding( method, null, SignalInputKind.TriggerMethod );
return true;
}
if ( parameters.Length > 1 ) return false;
var parameterType = parameters[0].ParameterType;
var kind = parameterType == typeof( SignalEvent ) ? SignalInputKind.EventMethod
: parameterType == typeof( bool ) ? SignalInputKind.BoolMethod
: parameterType == typeof( float ) ? SignalInputKind.FloatMethod
: (SignalInputKind?)null;
if ( kind is null ) return false;
binding = new SignalInputBinding( method, null, kind.Value );
return true;
}
private static bool TryBindProperty( PropertyDescription property, SignalInputAttribute attribute, out SignalInputBinding binding )
{
binding = default;
if ( property.PropertyType == typeof( bool ) && property.CanWrite )
{
binding = new SignalInputBinding( null, property, SignalInputKind.BoolProperty );
return true;
}
if ( property.PropertyType == typeof( float ) && property.CanWrite )
{
binding = new SignalInputBinding( null, property, SignalInputKind.FloatProperty );
return true;
}
if ( property.PropertyType.IsAssignableTo( typeof( Component ) ) && property.CanWrite )
{
binding = new SignalInputBinding( null, property, SignalInputKind.ComponentProperty );
return true;
}
if ( FindAdapter( property.PropertyType ) is { } adapter )
{
try
{
return adapter.TryBind( new SignalInputContext( property, attribute ), out binding );
}
catch ( Exception exception )
{
Log.Warning( $"Signal adapter for {property.PropertyType.FullName} failed to bind {property.Name}: {exception.Message}" );
}
}
return false;
}
private static ISignalAdapter FindAdapter( Type targetType )
{
ISignalAdapter result = null;
foreach ( var description in Game.TypeLibrary.GetTypes<ISignalAdapter>().Where( type => !type.IsAbstract ).OrderBy( type => type.FullName, StringComparer.Ordinal ) )
{
var adapter = Game.TypeLibrary.Create<ISignalAdapter>( description.TargetType );
if ( adapter?.TargetType is null )
{
Log.Warning( $"Signal adapter {description.FullName} could not be created. Adapters need a parameterless constructor and a TargetType." );
continue;
}
if ( adapter.TargetType != targetType ) continue;
if ( result is not null )
{
Log.Warning( $"Signal adapters for {targetType.Name} are ambiguous; refusing to choose one." );
return null;
}
result = adapter;
}
return result;
}
}