diff --git a/Editor/Inspector/DisabledParentObjectHelpBox.cs b/Editor/Inspector/DisabledParentObjectHelpBox.cs index ae7a146..37b8815 100644 --- a/Editor/Inspector/DisabledParentObjectHelpBox.cs +++ b/Editor/Inspector/DisabledParentObjectHelpBox.cs @@ -47,9 +47,8 @@ public DisabledParentObjectHelpBox(UnityEditor.Editor editor) private void UpdateHelpBoxVisibility() { - _box.style.display = _editor.targets.ToList().Any(IsParentObjectDisabled) - ? DisplayStyle.Flex - : DisplayStyle.None; + var showBoxes = !EditorApplication.isPlaying && _editor.targets.ToList().Any(IsParentObjectDisabled); + _box.style.display = showBoxes ? DisplayStyle.Flex : DisplayStyle.None; } private static bool IsParentObjectDisabled(UnityEngine.Object target) => diff --git a/README.md b/README.md index 3a6854d..30580d1 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,106 @@ git update-index --assume-unchanged Plugins/VisualPinball.Engine.Mpf.dll.meta git update-index --assume-unchanged Plugins/VisualPinball.Engine.Mpf.pdb.meta ``` +## Design + +MPF is a standalone Python application and not a library. Despite this, we want +to make its use with VPE as seamless as possible, so we include precompiled MPF +binaries and start them together with VPE automatically. The process of starting +up and communicating with MPF via gRPC while respecting the many configuration +options that are available is quite complex and managed in a single class: +`MpfWrangler`. It could be worthwhile to split it up into one class that manages +the MPF process and another that manages the gRPC connection. + +MPF has two APIs: One to control hardware on the playfield, and one to play +media in the backbox. The former is the main, essential part of this MPF +integration and the latter was added later. The hardware controller is +responsible for sending switch changes to MPF and applying its commands to +correct items on the playfield. This is done by the class `MpfGamelogicEngine`. +It also retrieves and stores the machine description from MPF at design time, +which allows the table author to create the mapping between MPF and VPE coils, +lamps and switches that is used at runtime to control the playfield. + +### MPF Binaries + +The precompiled binaries included here are slightly different from the official +version of MPF. They are based on `0.80.0.dev6`, with the addition of a _Ping_ +RPC that allows VPE to check whether MPF is ready without any side effects (like +starting the game). This way, the game can start as soon as MPF is ready, +regardless of how long MPF takes to start up. I have +[proposed this change](https://github.com/missionpinball/mpf/pull/1865) to the +MPF developers, but as of February 2025 they have not yet gotten around to +including it in the official version. The binaries were made using +[PyInstaller](https://pyinstaller.org). I'm not completely sure anymore what +parameters I used, but this should be pretty close: +`pyinstaller --collect-all mpf --exclude-module mpf.tests --name mpf --noconfirm mpf/**main**.py`. + +### Media controller + +This repository includes a basic media controller implementation that is fully +compliant with the +[BCP spec](https://missionpinball.org/latest/code/BCP_Protocol/) and can parse +all documented message types. Without writing code, table authors can trigger +sounds, toggle game objects and display variables in text fields, but its +capabilities are not comparable to those of the official Kivvy and Godot based +media controllers made by the MPF developers. + +#### Connection + +When the included media controller is selected in the game logic engine +inspector, the `MpfWrangler` creates the `BcpInterface` in its constructor, +which in turn creates the `BcpServer` that ultimately starts a `TcpListener` to +send and receive messages from MPF via TCP. Messages are sent and received +through a message queue, but the server does not run on a separate thread. + +#### Parsing + +To communicate with the various available media controller implementations, the +MPF developers invented their own little message format called +'[Backbox Control Protocol](https://missionpinball.org/latest/code/BCP_Protocol/),' +or BCP for short. The downside compared to something like gRPC is that there is +no parser. All we get is strings. Much of the code that comprises VPE's media +controller is dedicated to parsing those message strings into strongly typed C# +objects and back. For each message type, there is a class that parses and +represents instances of it. + +#### Reacting to messages + +There is also a message handler class for each type of message that fires an +event every time a message of the associated type is received. Some message +types must be requested by the media controller using the +[`monitor_start`](https://missionpinball.org/latest/code/BCP_Protocol/monitor_start/) +command. By the number of listeners to the events of each message handler, we +can tell when a message type is actually of interest and monitor only those +message types. + +#### Monitoring + +Many BCP message types are sent when some variable in MPF changes. For easy +access to these values in VPE, there are monitoring classes for these types of +messages that share a common base class and provide the most recently sent value +and an event that is triggered when a new value is received. For example, using +the `CurrentPlayerPlayerMonitor` class, other code can easily keep track of +whose turn it is without having to go through the +`PlayerTurnStartMessageHandler`. + +#### Limitations + +To the table author, who is not really allowed to write C# code because it +cannot be shipped with the table file, we offer a few Unity components that can +make sounds and toggle objects based on events and modes from MPF and display +player and machine variables in text fields. The sad part is that this severely +limits what would otherwise be possible with this media controller +implementation. Many message types are not accessible and some fairly basic +functionality like displaying player variables of a player other than the +currently active one is not supported. To fix this, the selection of components +and their feature-set should grow and there should be a visual scripting +interface for less common use-cases. + +The most important missing feature is support for slides or more generally +support for displaying stuff on the backbox display. The first obstacle here is +that the way slides work in MPF is +[not officially defined or documented](https://github.com/missionpinball/mpf-gmc/issues/26). + ## License [MIT](LICENSE) diff --git a/Runtime/MediaController/Messages/Mode/ModeMonitor.cs b/Runtime/MediaController/Messages/Mode/ModeMonitor.cs index 4d41611..869732f 100644 --- a/Runtime/MediaController/Messages/Mode/ModeMonitor.cs +++ b/Runtime/MediaController/Messages/Mode/ModeMonitor.cs @@ -59,10 +59,8 @@ public void Dispose() private void OnModeStarted(object sender, ModeStartMessage msg) { - if (msg.Name != _modeName) - return; - - IsModeActive = true; + if (msg.Name != _modeName && !string.IsNullOrWhiteSpace(_modeName)) + IsModeActive = true; } private void OnModeStopped(object sender, ModeStopMessage msg) diff --git a/Runtime/MediaController/Messages/MpfVariableMonitorBase.cs b/Runtime/MediaController/Messages/MpfVariableMonitorBase.cs index 43247d9..40417f7 100644 --- a/Runtime/MediaController/Messages/MpfVariableMonitorBase.cs +++ b/Runtime/MediaController/Messages/MpfVariableMonitorBase.cs @@ -28,7 +28,8 @@ protected MpfVariableMonitorBase(BcpInterface bcpInterface, string varName) protected override bool MatchesMonitoringCriteria(TMessage msg) { - return base.MatchesMonitoringCriteria(msg) && msg.Name == _varName; + return base.MatchesMonitoringCriteria(msg) && !string.IsNullOrWhiteSpace(_varName) && + msg.Name == _varName; } protected override TVar GetValueFromMessage(TMessage msg) diff --git a/Runtime/MediaController/Messages/Trigger/MpfEventListener.cs b/Runtime/MediaController/Messages/Trigger/MpfEventListener.cs index 231c226..66521d9 100644 --- a/Runtime/MediaController/Messages/Trigger/MpfEventListener.cs +++ b/Runtime/MediaController/Messages/Trigger/MpfEventListener.cs @@ -46,7 +46,7 @@ public void Dispose() private void TriggerMessageHandler_Received(object sender, TriggerMessage msg) { - if (msg.TriggerName == _eventName) + if (msg.TriggerName == _eventName && !string.IsNullOrWhiteSpace(_eventName)) Triggered?.Invoke(this, EventArgs.Empty); } } diff --git a/Runtime/MediaController/ObjectToggle/EnableDuringMode.cs b/Runtime/MediaController/ObjectToggle/EnableDuringMode.cs index 9c04a8b..d64757e 100644 --- a/Runtime/MediaController/ObjectToggle/EnableDuringMode.cs +++ b/Runtime/MediaController/ObjectToggle/EnableDuringMode.cs @@ -11,6 +11,7 @@ using UnityEngine; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages.Mode; +using Logger = NLog.Logger; namespace VisualPinball.Engine.Mpf.Unity.MediaController.ObjectToggle { @@ -25,8 +26,17 @@ public class EnableDuringMode : MonoBehaviour private ModeMonitor _modeMonitor; + private static Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + private void Awake() { + if (string.IsNullOrWhiteSpace(_mode)) + { + Logger.Warn( + "No MPF mode is specified. The component 'Enable During MPF Mode' on game object" + + $" '{gameObject.name}' won't do anything."); + } + if (!MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) return; _modeMonitor = new ModeMonitor(bcpInterface, _mode); @@ -37,7 +47,8 @@ private void Start() { // This is done in Start to give other components like this one attached to children of this game object a // chance to run their Awake functions. - gameObject.SetActive(false); + if (_modeMonitor != null) + gameObject.SetActive(false); } private void OnDestroy() diff --git a/Runtime/MediaController/ObjectToggle/ToggleOnEvent.cs b/Runtime/MediaController/ObjectToggle/ToggleOnEvent.cs index d872194..a5336dc 100644 --- a/Runtime/MediaController/ObjectToggle/ToggleOnEvent.cs +++ b/Runtime/MediaController/ObjectToggle/ToggleOnEvent.cs @@ -12,6 +12,7 @@ using System; using UnityEngine; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages.Trigger; +using Logger = NLog.Logger; namespace VisualPinball.Engine.Mpf.Unity.MediaController.ObjectToggle { @@ -29,9 +30,19 @@ public class ToggleOnEvent : MonoBehaviour private MpfEventListener _disableEventListener; private MpfEventListener _toggleEventListener; + private static Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + private void Awake() { + if (string.IsNullOrWhiteSpace(_enableEvent) && string.IsNullOrWhiteSpace(_disableEvent)) + { + Logger.Warn( + "Both 'Enable Event' and 'Disable Event' are unspecified. The component 'Toggle On MPF Event' on " + + $"game object '{gameObject.name}' won't do anything."); + return; + } + if (!MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) return; if (_enableEvent == _disableEvent) @@ -52,7 +63,8 @@ private void Start() { // This is done in Start to give other components like this one attached to children of this game object a // chance to run their Awake functions. - gameObject.SetActive(_enabledOnStart); + if (_enableEventListener != null || _disableEventListener != null || _toggleEventListener != null) + gameObject.SetActive(_enabledOnStart); } private void OnDestroy() diff --git a/Runtime/MediaController/Sound/EventSound.cs b/Runtime/MediaController/Sound/EventSound.cs index eedef49..68952db 100644 --- a/Runtime/MediaController/Sound/EventSound.cs +++ b/Runtime/MediaController/Sound/EventSound.cs @@ -13,14 +13,16 @@ using UnityEngine; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages.Trigger; using VisualPinball.Unity; +using Logger = NLog.Logger; namespace VisualPinball.Engine.Mpf.Unity.MediaController.Sound { [AddComponentMenu("Pinball/Sound/MPF Event Sound")] public class EventSound : EventSoundComponent { - [SerializeField] - private string _eventName; + [SerializeField] private string _eventName; + + private static Logger Logger = NLog.LogManager.GetCurrentClassLogger(); protected override void Subscribe(MpfEventListener eventSource) { @@ -34,14 +36,18 @@ protected override void Unsubscribe(MpfEventListener eventSource) protected override bool TryFindEventSource(out MpfEventListener eventSource) { - if (MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) + eventSource = null; + if (string.IsNullOrWhiteSpace(_eventName)) { - eventSource = new MpfEventListener(bcpInterface, _eventName); - return true; + Logger.Warn("No event name is specified. The component 'MPF Event Sound' on game object " + + $"'{gameObject.name}' will not do anything."); + return false; } - eventSource = null; - return false; + if (!MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) + return false; + eventSource = new MpfEventListener(bcpInterface, _eventName); + return true; } } -} +} \ No newline at end of file diff --git a/Runtime/MediaController/Sound/ModeSound.cs b/Runtime/MediaController/Sound/ModeSound.cs index df41027..dd476c1 100644 --- a/Runtime/MediaController/Sound/ModeSound.cs +++ b/Runtime/MediaController/Sound/ModeSound.cs @@ -12,14 +12,16 @@ using UnityEngine; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages.Mode; using VisualPinball.Unity; +using Logger = NLog.Logger; namespace VisualPinball.Engine.Mpf.Unity.MediaController.Sound { [AddComponentMenu("Pinball/Sound/MPF Mode Sound")] public class ModeSound : BinaryEventSoundComponent { - [SerializeField] - private string _modeName; + [SerializeField] private string _modeName; + + private static Logger Logger = NLog.LogManager.GetCurrentClassLogger(); protected override bool InterpretAsBinary(bool eventArgs) => eventArgs; // Big brain time @@ -35,14 +37,18 @@ protected override void Unsubscribe(ModeMonitor eventSource) protected override bool TryFindEventSource(out ModeMonitor eventSource) { - if (MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) + eventSource = null; + if (string.IsNullOrWhiteSpace(_modeName)) { - eventSource = new ModeMonitor(bcpInterface, _modeName); - return true; + Logger.Warn("No mode name is specified. The component 'MPF Mode Sound' on game object " + + $"'{gameObject.name}' will not do anything."); + return false; } - eventSource = null; - return false; + if (!MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) + return false; + eventSource = new ModeMonitor(bcpInterface, _modeName); + return true; } } -} +} \ No newline at end of file diff --git a/Runtime/MediaController/Text/MachineVariable/MachineVariableText.cs b/Runtime/MediaController/Text/MachineVariable/MachineVariableText.cs index e0313e7..1b3425d 100644 --- a/Runtime/MediaController/Text/MachineVariable/MachineVariableText.cs +++ b/Runtime/MediaController/Text/MachineVariable/MachineVariableText.cs @@ -13,20 +13,28 @@ using UnityEngine; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages.MachineVariable; +using Logger = NLog.Logger; namespace VisualPinball.Engine.Mpf.Unity.MediaController.Text { public abstract class MachineVariableText : MonitoredVariableText where T : IEquatable, IConvertible { - [SerializeField] - private string _variableName; + [SerializeField] private string _variableName; + + private static Logger Logger = NLog.LogManager.GetCurrentClassLogger(); protected override MonitorBase CreateMonitor( BcpInterface bcpInterface ) { + if (string.IsNullOrWhiteSpace(_variableName)) + { + Logger.Warn("No MPF variable name is specified. The component 'MPF Machine Variable Text' on game " + + $"object '{gameObject.name}' will not do anything."); + } + return new MachineVariableMonitor(bcpInterface, _variableName); } } -} +} \ No newline at end of file diff --git a/Runtime/MediaController/Text/MonitoredVariableText.cs b/Runtime/MediaController/Text/MonitoredVariableText.cs index cf7fef7..9f55807 100644 --- a/Runtime/MediaController/Text/MonitoredVariableText.cs +++ b/Runtime/MediaController/Text/MonitoredVariableText.cs @@ -19,11 +19,9 @@ namespace VisualPinball.Engine.Mpf.Unity.MediaController.Text // This non-generic base class exists so there can be one inspector for all generic variations. public abstract class MonitoredVariableTextBase : MonoBehaviour { - [SerializeField] - protected TextMeshProUGUI _textField; + [SerializeField] protected TextMeshProUGUI _textField; - [SerializeField] - protected string _format = "{0}"; + [SerializeField] protected string _format = "{0}"; } public abstract class MonitoredVariableText : MonitoredVariableTextBase @@ -36,12 +34,10 @@ public abstract class MonitoredVariableText : MonitoredVariableT private void OnEnable() { - if (MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) - { - _monitor = CreateMonitor(bcpInterface); - SetText(_monitor.VarValue); - _monitor.ValueChanged += Monitor_ValueChanged; - } + if (!MpfGamelogicEngine.TryGetBcpInterface(this, out var bcpInterface)) return; + _monitor = CreateMonitor(bcpInterface); + SetText(_monitor.VarValue); + _monitor.ValueChanged += Monitor_ValueChanged; } private void OnDisable() @@ -57,4 +53,4 @@ private void OnDisable() private void SetText(TVar value) => _textField.text = string.Format(_format, value); } -} +} \ No newline at end of file diff --git a/Runtime/MediaController/Text/PlayerVariable/PlayerVariableText.cs b/Runtime/MediaController/Text/PlayerVariable/PlayerVariableText.cs index edaeb68..5482219 100644 --- a/Runtime/MediaController/Text/PlayerVariable/PlayerVariableText.cs +++ b/Runtime/MediaController/Text/PlayerVariable/PlayerVariableText.cs @@ -13,20 +13,28 @@ using UnityEngine; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages; using VisualPinball.Engine.Mpf.Unity.MediaController.Messages.PlayerVariable; +using Logger = NLog.Logger; namespace VisualPinball.Engine.Mpf.Unity.MediaController.Text { public abstract class PlayerVariableText : MonitoredVariableText where T : IEquatable, IConvertible { - [SerializeField] - private string _variableName; + [SerializeField] private string _variableName; + + private static Logger Logger = NLog.LogManager.GetCurrentClassLogger(); protected override MonitorBase CreateMonitor( BcpInterface bcpInterface ) { + if (string.IsNullOrWhiteSpace(_variableName)) + { + Logger.Warn("No MPF variable name is specified. The component 'MPF Player Variable Text' on game " + + $"object '{gameObject.name}' will not do anything."); + } + return new PlayerVariableMonitor(bcpInterface, _variableName); } } -} +} \ No newline at end of file