Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions Editor/Inspector/DisabledParentObjectHelpBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 2 additions & 4 deletions Runtime/MediaController/Messages/Mode/ModeMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Runtime/MediaController/Messages/MpfVariableMonitorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
13 changes: 12 additions & 1 deletion Runtime/MediaController/ObjectToggle/EnableDuringMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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);
Expand All @@ -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()
Expand Down
14 changes: 13 additions & 1 deletion Runtime/MediaController/ObjectToggle/ToggleOnEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)
Expand All @@ -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()
Expand Down
22 changes: 14 additions & 8 deletions Runtime/MediaController/Sound/EventSound.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MpfEventListener, EventArgs>
{
[SerializeField]
private string _eventName;
[SerializeField] private string _eventName;

private static Logger Logger = NLog.LogManager.GetCurrentClassLogger();

protected override void Subscribe(MpfEventListener eventSource)
{
Expand All @@ -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;
}
}
}
}
22 changes: 14 additions & 8 deletions Runtime/MediaController/Sound/ModeSound.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModeMonitor, bool>
{
[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

Expand All @@ -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;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> : MonitoredVariableText<T, MachineVariableMessage>
where T : IEquatable<T>, IConvertible
{
[SerializeField]
private string _variableName;
[SerializeField] private string _variableName;

private static Logger Logger = NLog.LogManager.GetCurrentClassLogger();

protected override MonitorBase<T, MachineVariableMessage> 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<T>(bcpInterface, _variableName);
}
}
}
}
18 changes: 7 additions & 11 deletions Runtime/MediaController/Text/MonitoredVariableText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TVar, TMessage> : MonitoredVariableTextBase
Expand All @@ -36,12 +34,10 @@ public abstract class MonitoredVariableText<TVar, TMessage> : 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()
Expand All @@ -57,4 +53,4 @@ private void OnDisable()

private void SetText(TVar value) => _textField.text = string.Format(_format, value);
}
}
}
Loading