This tech tip walks through the design of MultiballMode in P3SampleApp, beginning with a skeletal implementation and incrementally refining it into a complete solution.
The starting point is a minimal implementation in which multiball starts when the side targets are completed.
Lines in bold indicate changes to the code.
HomeMode needs a member variable to store the instance of MultiballMode:
private MultiballMode multiballMode; |
HomeMode creates a MultiballMode instance in the constructor:
multiballMode = new MultiballMode(p3, P3SAPriorities.PRIORITY_MULTIBALL); |
The Mode priority is defined in P3SAPriorities with a value lower than the ball saver:
public const int PRIORITY_MULTIBALL = PRIORITY_HOME + 34; public const int PRIORITY_BALL_SAVE = PRIORITY_HOME + 55; |
When HomeMode stops, it also stops multiballMode in mode_stopped():
p3.RemoveMode(multiballMode); |
HomeMode registers a handler in its constructor for when the side targets complete:
AddModeEventHandler("Evt_SideTargetComplete", SideTargetCompleteEventHandler, Priority); |
When the side targets are completed, HomeMode starts multiballMode:
private bool SideTargetCompleteEventHandler(string eventName, object eventData) { p3.AddMode(multiballMode); return EVENT_CONTINUE; } |
The task of MultiballMode is to launch the additional balls and stop itself when there is only one ball left in play. The drain switch must be handled to count the balls in play and stop the event from reaching HomeMode where it would end the ball.
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private int ballsInPlay; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { } public override void mode_started() { base.mode_started(); ballsInPlay = 1; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } } private void LaunchCallback() { ballsInPlay++; } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (ballsInPlay == 1) { p3.RemoveMode(this); } return SWITCH_STOP; } } } |
The previous implementation has a problem: when multiball ends, the side targets do not reset, leaving multiball unreachable for the remainder of the player’s game.
MultiballMode needs to tell HomeMode when multiball has ended so that HomeMode can restore normal play.
MultiballMode.cs is modified to send the Evt_MultiballEnded event instead of removing itself:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private int ballsInPlay; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { } public override void mode_started() { base.mode_started(); ballsInPlay = 1; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } } private void LaunchCallback() { ballsInPlay++; } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
HomeMode registers a handler in its constructor for when multiball ends:
AddModeEventHandler("Evt_MultiballEnded", MultiballEndedEventHandler, Priority); |
When multiball ends, HomeMode removes multiballMode and resets the side targets so they can be completed again:
public bool MultiballEndedEventHandler(string eventName, object eventData) { p3.RemoveMode(multiballMode); PostModeEventToModes("Evt_SideTargetReset", 0); return EVENT_CONTINUE; } |
This is the preferred approach in the SDK. The parent Mode should control the life cycle of its child Modes.
The previous implementation has a problem if the second ball drains before the third ball is launched successfully. That’s because ballsInPlay drops to 1 even though there are more balls to launch. We need to adjust the criteria to consider the pending launches.
Notice the multiball will end if both the first and second balls drain before the third ball is launched. That’s the desired behavior. For a brief moment, there are no balls in play but the player’s ball is still alive. The pending third launch will eventually succeed and call the LaunchCallback on a stopped mode. This can be a big problem in general, but it turns out to be harmless in this case. HomeMode keeps a reference to MultiballMode, so the object is not destroyed. The LaunchCallback merely assigns to member variables, so there will never be a NullReferenceException. All is good and the player will continue to play with that ball.
MultiballMode.cs is modified to track the number of pending launches:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private int ballsInPlay; private int pendingLaunches; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
It is customary for multiball to start with a ball saver. Starting the ball saver is as simple as sending the Evt_BallSaveAdd event to increase the ball saver timer. If the ball saver is already running, it will increase the timer, otherwise it will start the ball saver with that timeout.
BallSaveMode handles the Evt_BallSaveAdd event. HomeMode starts BallSaveMode at the start of the ball and removes it when the ball ends. To be clear, this starts the Mode, it does not start the ball saver.
BallSaveMode has a higher priority than MultiballMode. When a ball drains with the ball saver active, BallSaveMode handles the drain event by launching a new ball and stops the event from reaching lower priority modes. MultiballMode is not aware the ball drained and considers the drained ball and the newly launched ball to be the same.
The Evt_BallSavePauseUntilGrid event tells the ball saver to pause the timer and restart the timer only when at least one ball is over the screen. This is a convenient way to account for the variable time it might take to launch balls. The event argument is ignored.
We also disable the visual feedback shown when a ball is saved. This feedback can become frequent and distracting during multiball. The feedback is restored when multiball ends.
Notice it’s impossible for MultiballMode to end when the ball saver timer is still active because sw_drain_active() is not called. This means there is never a need to stop the ball saver when MultiballMode ends. Technically, this might still occur when tilting, but tilt handling takes care of that scenario.
MultiballMode.cs is modified to implement a ball saver:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private const int BALL_SAVE_TIME = 10; private int ballsInPlay; private int pendingLaunches; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } PostModeEventToModes("Evt_BallSaveAdd", BALL_SAVE_TIME); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); PostModeEventToModes("Evt_EnableBallSavedFeedback", false); } public override void mode_stopped() { PostModeEventToModes("Evt_EnableBallSavedFeedback", true); base.mode_stopped(); } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
When SceneMode receives an Evt_EnableBallSavedFeedback event with a false argument, it disables the ball saved animation it normally plays when it receives an Evt_BallSaved event from the ball saver.
SceneMode registers the handlers for these events in its constructor:
AddModeEventHandler("Evt_BallSaved", BallSavedEventHandler, Priority); AddModeEventHandler("Evt_EnableBallSavedFeedback", EnableBallSavedFeedbackEventHandler, Priority); |
The handlers look like this:
protected virtual bool BallSavedEventHandler(string evtName, object evtData) { P3SABallLauncher.delayed_launch(2.25); if (showBallSaved) PostModeEventToGUI("Evt_BallSavePlayAnimation", sceneName); return EVENT_STOP; }
private bool EnableBallSavedFeedbackEventHandler(string evtName, object evtData) { showBallSaved = (bool)evtData; return EVENT_STOP; } |
P3SampleApp has a Respawn award that can be earned by completing the lower lanes 10, 20, 40, 100, or 1000 times. A Respawn is a single-use virtual kickback for a ball that drains below the main flippers. RespawnMode has higher priority than MultiballMode and can cause confusion when both are active. In any case, the player would likely prefer to keep the kickback when losing his last ball, not when losing the third ball in multiball.
We will disable Respawn when MultiballMode starts and enable it again when MultiballMode ends.
MultiballMode.cs is modified to enable and disable Respawns:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private const int BALL_SAVE_TIME = 10; private int ballsInPlay; private int pendingLaunches; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } PostModeEventToModes("Evt_BallSaveAdd", BALL_SAVE_TIME); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); PostModeEventToModes("Evt_EnableBallSavedFeedback", false); PostModeEventToModes("Evt_RespawnEnable", false); } public override void mode_stopped() { PostModeEventToModes("Evt_EnableBallSavedFeedback", true); PostModeEventToModes("Evt_RespawnEnable", true); base.mode_stopped(); } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
The IR grid can only reliably locate a single ball. It gets confused when multiple balls are present on the grid simultaneously. It is recommended to disable all modes that rely on the IR grid when multiball starts. In P3SampleApp, that’s LanesMode, MovingTargetMode and SideTargetMode.
LanesMode handles the Evt_EnableLanes event where the bool event argument determines whether to enable or disable the lower lanes. MultiballMode can send that event when it starts and stops.
MovingTargetMode does not have a similar event, and MultiballMode does not have a reference to the MovingTargetMode instance. Disabling the moving target must be done in HomeMode. Again, it is a best practice to let the parent Mode control the life cycle of its child Modes.
There is a bug in P3SampleApp V0.9: it does not disable SideTargetMode during multiball.
MultiballMode.cs is modified to enable and disable the lower lanes:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private const int BALL_SAVE_TIME = 10; private int ballsInPlay; private int pendingLaunches; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } PostModeEventToModes("Evt_BallSaveAdd", BALL_SAVE_TIME); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); PostModeEventToModes("Evt_EnableBallSavedFeedback", false); PostModeEventToModes("Evt_RespawnEnable", false); PostModeEventToModes("Evt_EnableLanes", false); } public override void mode_stopped() { PostModeEventToModes("Evt_EnableBallSavedFeedback", true); PostModeEventToModes("Evt_RespawnEnable", true); PostModeEventToModes("Evt_EnableLanes", true); base.mode_stopped(); } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
In HomeMode, movingTargetMode is removed when multiball starts and added when multiball ends. We are also fixing the bug by removing and adding sideTargetMode. We must be careful to send the Evt_SideTargetReset event after sideTargetMode is added to the mode queue:
private bool SideTargetCompleteEventHandler(string eventName, object eventData) { p3.RemoveMode(movingTargetMode); p3.RemoveMode(sideTargetMode); p3.AddMode(multiballMode); return EVENT_CONTINUE; } private bool MultiballEndedEventHandler(string eventName, object eventData) { p3.RemoveMode(multiballMode); p3.AddMode(movingTargetMode); p3.AddMode(sideTargetMode); PostModeEventToModes("Evt_SideTargetReset", 0); return EVENT_CONTINUE; } |
With lower lanes, the moving target and the side targets disabled, there is not much to do in the current multiball. To remain module-agnostic, we will use a scoop for jackpots.
When multiball starts, a scoop is chosen at random from 0 to 6 exclusive, so that’s 0 to 5 inclusive. The selected scoop remains active for the duration of the multiball.
The raised scoop and the corresponding wall are illuminated yellow. The other scoops and walls are illuminated blue. The LEDScripts are removed automatically when the Mode ends because we reused the LEDScripts in LEDScriptsDict.
The first generation of the wall/scoop assembly had a single scoop switch and relied on the IR Grid to identify which scoop was entered. This approach is sometimes unreliable and can report a scoop number different from the actual scoop that was hit. In this MultiballMode, the scoop number reported by the Evt_ScoopHit event is ignored because there is only one scoop raised. The same approach works if all raised scoops are consecutive, together forming a single entrance. If your game needs to distinguish exactly which scoop was entered, it is best to keep at least one, and preferably two, scoops between the groups that must be distinguished.
When a jackpot is hit, a blinking message is shown to provide feedback. This is particularly important in the simulator, where there would otherwise be little indication that a scoop was hit.
MultiballMode.cs is modified to implement jackpots using a single scoop:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private const int BALL_SAVE_TIME = 10; private int ballsInPlay; private int pendingLaunches; private Random random; private int scoopIndex; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { AddModeEventHandler("Evt_ScoopHit", ScoopHitEventHandler, Priority); random = new Random(); } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } PostModeEventToModes("Evt_BallSaveAdd", BALL_SAVE_TIME); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); PostModeEventToModes("Evt_EnableBallSavedFeedback", false); PostModeEventToModes("Evt_RespawnEnable", false); PostModeEventToModes("Evt_EnableLanes", false); scoopIndex = random.Next(0, 6); p3.wallScoopMode.RaiseScoop(scoopIndex); UpdateLEDs(); } public override void mode_stopped() { p3.wallScoopMode.LowerScoop(scoopIndex); PostModeEventToModes("Evt_EnableBallSavedFeedback", true); PostModeEventToModes("Evt_RespawnEnable", true); PostModeEventToModes("Evt_EnableLanes", true); base.mode_stopped(); } private void UpdateLEDs() { for (int i = 0; i < 6; i++) { ushort[] color; if (i != scoopIndex) color = Multimorphic.P3.Colors.Color.blue; else color = Multimorphic.P3.Colors.Color.yellow; LEDHelpers.OnLED(p3, LEDScriptsDict["scoop" + i], color); LEDHelpers.OnLED(p3, LEDScriptsDict["wall" + i], color); } } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } private bool ScoopHitEventHandler(string evtName, object evtData) { // We need to account for possible grid inaccuracies. // We could check if the reported scoop is close to the raised scoop, // but we choose to accept all scoop hits instead. // int reportedScoop = (int)evtData; ScoreManager.Score(Scores.MULTIBALL_JACKPOT); PostModeEventToModes("Evt_BlinkPopup", "Jackpot"); return EVENT_CONTINUE; } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
The score value for a multiball jackpot defined in P3SAScoreValues.cs:
public const long MULTIBALL_JACKPOT = 40000; |
To raise the excitement and reward skilled players that can control 2 balls, we introduce a super jackpot when hitting a consecutive jackpot within 5 seconds.
The raised scoop is illuminated red when the super jackpot is active. We must update the LED colors when enabling or disabling the super jackpot.
The SuperJackpotTimer delay never needs to be cancelled explicitly. Registering a delay cancels any existing delay of the same name. A delay is automatically cancelled when the Mode is stopped.
MultiballMode.cs is modified to implement super jackpots:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private const int BALL_SAVE_TIME = 10; private const float SUPER_JACKPOT_TIMEOUT = 5.0f; private int ballsInPlay; private int pendingLaunches; private Random random; private int scoopIndex; private bool isSuperJackpot; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { AddModeEventHandler("Evt_ScoopHit", ScoopHitEventHandler, Priority); random = new Random(); } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } PostModeEventToModes("Evt_BallSaveAdd", BALL_SAVE_TIME); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); PostModeEventToModes("Evt_EnableBallSavedFeedback", false); PostModeEventToModes("Evt_RespawnEnable", false); PostModeEventToModes("Evt_EnableLanes", false); scoopIndex = random.Next(0, 6); p3.wallScoopMode.RaiseScoop(scoopIndex); isSuperJackpot = false; UpdateLEDs(); } public override void mode_stopped() { p3.wallScoopMode.LowerScoop(scoopIndex); PostModeEventToModes("Evt_EnableBallSavedFeedback", true); PostModeEventToModes("Evt_RespawnEnable", true); PostModeEventToModes("Evt_EnableLanes", true); base.mode_stopped(); } private void UpdateLEDs() { for (int i = 0; i < 6; i++) { ushort[] color; if (i != scoopIndex) color = Multimorphic.P3.Colors.Color.blue; else if (isSuperJackpot) color = Multimorphic.P3.Colors.Color.red; else color = Multimorphic.P3.Colors.Color.yellow; LEDHelpers.OnLED(p3, LEDScriptsDict["scoop" + i], color); LEDHelpers.OnLED(p3, LEDScriptsDict["wall" + i], color); } } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } private bool ScoopHitEventHandler(string evtName, object evtData) { // We need to account for possible grid inaccuracies. // We could check if the reported scoop is close to the raised scoop, // but we choose to accept all scoop hits instead. // int reportedScoop = (int)evtData; if (isSuperJackpot) { ScoreManager.Score(Scores.MULTIBALL_SUPER_JACKPOT); PostModeEventToModes("Evt_BlinkPopup", "Super Jackpot"); } else { ScoreManager.Score(Scores.MULTIBALL_JACKPOT); PostModeEventToModes("Evt_BlinkPopup", "Jackpot"); isSuperJackpot = true; UpdateLEDs(); } delay("SuperJackpotTimer", NetProc.EventType.None, SUPER_JACKPOT_TIMEOUT, new Multimorphic.P3.VoidDelegateNoArgs(EndSuperJackpot)); return EVENT_CONTINUE; } private void EndSuperJackpot() { isSuperJackpot = false; UpdateLEDs(); } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
The score value for a multiball super jackpot is defined in P3SAScoreValues.cs:
public const long MULTIBALL_SUPER_JACKPOT = 90000; |
We can play background music by requesting a playlist. A playlist is a component on the root of the P3SAAudio.prefab. The MultiballA playlist contains a single AudioClip stored in Assets/Resources/Sound/Music/SportsAction.mp3
We must remove the playlist requests when the mode is stopped.
We can play a sound when a jackpot is hit.
The name “Jackpot” refers to an AudioClipGroup in the Home scene. The group contains 7 VariableAudioClips which point to Assets/Resources/Sound/FX/FX_Jackpot_00N.wav where N is 1 to 7
Similarly, the name “SuperJackpot” refers to an AudioClipGroup in the Home scene. The group contains 7 VariableAudioClips which point to Assets/Resources/Sound/FX/FX_Super_Jackpot_00N.wav where N is 1 to 7
When playing the sound, the SDK will pick one of the VariableAudioClips in the group at random. All the VariableAudioClips above have their Weight equal to 1, making the 7 clips equally likely to play.
MultiballMode.cs is modified to play audio:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private const int BALL_SAVE_TIME = 10; private const float SUPER_JACKPOT_TIMEOUT = 5.0f; private int ballsInPlay; private int pendingLaunches; private Random random; private int scoopIndex; private bool isSuperJackpot; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { AddModeEventHandler("Evt_ScoopHit", ScoopHitEventHandler, Priority); random = new Random(); } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; RequestPlaylist("MultiballA"); for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } PostModeEventToModes("Evt_BallSaveAdd", BALL_SAVE_TIME); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); PostModeEventToModes("Evt_EnableBallSavedFeedback", false); PostModeEventToModes("Evt_RespawnEnable", false); PostModeEventToModes("Evt_EnableLanes", false); scoopIndex = random.Next(0, 6); p3.wallScoopMode.RaiseScoop(scoopIndex); isSuperJackpot = false; UpdateLEDs(); } public override void mode_stopped() { p3.wallScoopMode.LowerScoop(scoopIndex); RemovePlaylistRequests(); PostModeEventToModes("Evt_EnableBallSavedFeedback", true); PostModeEventToModes("Evt_RespawnEnable", true); PostModeEventToModes("Evt_EnableLanes", true); base.mode_stopped(); } private void UpdateLEDs() { for (int i = 0; i < 6; i++) { ushort[] color; if (i != scoopIndex) color = Multimorphic.P3.Colors.Color.blue; else if (isSuperJackpot) color = Multimorphic.P3.Colors.Color.red; else color = Multimorphic.P3.Colors.Color.yellow; LEDHelpers.OnLED(p3, LEDScriptsDict["scoop" + i], color); LEDHelpers.OnLED(p3, LEDScriptsDict["wall" + i], color); } } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } private bool ScoopHitEventHandler(string evtName, object evtData) { // We need to account for possible grid inaccuracies. // We could check if the reported scoop is close to the raised scoop, // but we choose to accept all scoop hits instead. // int reportedScoop = (int)evtData; if (isSuperJackpot) { ScoreManager.Score(Scores.MULTIBALL_SUPER_JACKPOT); PlaySound("SuperJackpot"); PostModeEventToModes("Evt_BlinkPopup", "Super Jackpot"); } else { ScoreManager.Score(Scores.MULTIBALL_JACKPOT); PlaySound("Jackpot"); PostModeEventToModes("Evt_BlinkPopup", "Jackpot"); isSuperJackpot = true; UpdateLEDs(); } delay("SuperJackpotTimer", NetProc.EventType.None, SUPER_JACKPOT_TIMEOUT, new Multimorphic.P3.VoidDelegateNoArgs(EndSuperJackpot)); return EVENT_CONTINUE; } private void EndSuperJackpot() { isSuperJackpot = false; UpdateLEDs(); } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
When the player tilts, we cannot simply remove the MultiballMode because we must keep track of the number of balls in play. TiltedMode overrides the scoop switches, so the player cannot score points as the balls drain. When a single ball remains, MultiballMode will end as usual.
We must handle the Evt_TiltProcess event to remove the playlist requests. We want the game to be quiet when processing a tilt.
MultiballMode.cs is modified to handle tilting:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private const int BALL_SAVE_TIME = 10; private const float SUPER_JACKPOT_TIMEOUT = 5.0f; private int ballsInPlay; private int pendingLaunches; private Random random; private int scoopIndex; private bool isSuperJackpot; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { AddModeEventHandler("Evt_ScoopHit", ScoopHitEventHandler, Priority); AddModeEventHandler("Evt_TiltProcess", TiltProcessEventHandler, Priority); random = new Random(); } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; RequestPlaylist("MultiballA"); for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } PostModeEventToModes("Evt_BallSaveAdd", BALL_SAVE_TIME); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); PostModeEventToModes("Evt_EnableBallSavedFeedback", false); PostModeEventToModes("Evt_RespawnEnable", false); PostModeEventToModes("Evt_EnableLanes", false); scoopIndex = random.Next(0, 6); p3.wallScoopMode.RaiseScoop(scoopIndex); isSuperJackpot = false; UpdateLEDs(); } public override void mode_stopped() { p3.wallScoopMode.LowerScoop(scoopIndex); RemovePlaylistRequests(); PostModeEventToModes("Evt_EnableBallSavedFeedback", true); PostModeEventToModes("Evt_RespawnEnable", true); PostModeEventToModes("Evt_EnableLanes", true); base.mode_stopped(); } private void UpdateLEDs() { for (int i = 0; i < 6; i++) { ushort[] color; if (i != scoopIndex) color = Multimorphic.P3.Colors.Color.blue; else if (isSuperJackpot) color = Multimorphic.P3.Colors.Color.red; else color = Multimorphic.P3.Colors.Color.yellow; LEDHelpers.OnLED(p3, LEDScriptsDict["scoop" + i], color); LEDHelpers.OnLED(p3, LEDScriptsDict["wall" + i], color); } } private bool TiltProcessEventHandler(string eventName, object eventData) { RemovePlaylistRequests(); return EVENT_CONTINUE; } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } private bool ScoopHitEventHandler(string evtName, object evtData) { // We need to account for possible grid inaccuracies. // We could check if the reported scoop is close to the raised scoop, // but we choose to accept all scoop hits instead. // int reportedScoop = (int)evtData; if (isSuperJackpot) { ScoreManager.Score(Scores.MULTIBALL_SUPER_JACKPOT); PlaySound("SuperJackpot"); PostModeEventToModes("Evt_BlinkPopup", "Super Jackpot"); } else { ScoreManager.Score(Scores.MULTIBALL_JACKPOT); PlaySound("Jackpot"); PostModeEventToModes("Evt_BlinkPopup", "Jackpot"); isSuperJackpot = true; UpdateLEDs(); } delay("SuperJackpotTimer", NetProc.EventType.None, SUPER_JACKPOT_TIMEOUT, new Multimorphic.P3.VoidDelegateNoArgs(EndSuperJackpot)); return EVENT_CONTINUE; } private void EndSuperJackpot() { isSuperJackpot = false; UpdateLEDs(); } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
We can let the player choose the parameters of MultiballMode to adjust the game difficulty.
The “Side Target Difficulty” setting is already implemented by SideTargetMode.
We will add settings for the multiball ball save time, and the super jackpot timeout. The PRW option means these are profile settings, so they can be different for each player. By design, the number of balls in MultiballMode is equal to 3 and this will not be configurable.
The multiball ball save time can be between 0 and 15 seconds in 1 second increments. The default is 10 seconds.
The super jackpot timeout can be between 0 and 10 seconds in 1 second increments. The default is 5 seconds.
P3SASettingsMode.cs declares the multiball GameAttributes:
InitAttr(37, "MultiballBallSaveTime", "Multiball Ball Save Time", "Multiball Ball Save Time", "Service Menu/Settings/Gameplay/General", PRW, 10, 0, 15, 1, 10); InitAttr(37, "MultiballSuperJackpotTimeout", "Multiball Super Jackpot Timeout", "Multiball Super Jackpot Timeout", "Service Menu/Settings/Gameplay/General", PRW, 5.0f, 0.0f, 10.0f, 1.0f, 5.0f); |
MultiballMode.cs is modified to use settings in the Service Menu:
using Multimorphic.NetProcMachine.Machine; using Multimorphic.P3; using Multimorphic.P3App.Modes; using System; namespace Multimorphic.P3SA.Modes { public class MultiballMode : P3SAGameMode { private const int BALLS_TO_LAUNCH = 2; private int ballsInPlay; private int pendingLaunches; private Random random; private int scoopIndex; private bool isSuperJackpot; public MultiballMode(P3Controller controller, int priority) : base(controller, priority) { AddModeEventHandler("Evt_ScoopHit", ScoopHitEventHandler, Priority); AddModeEventHandler("Evt_TiltProcess", TiltProcessEventHandler, Priority); random = new Random(); } public override void mode_started() { base.mode_started(); ballsInPlay = 1; pendingLaunches = BALLS_TO_LAUNCH; RequestPlaylist("MultiballA"); for (int i = 0; i < BALLS_TO_LAUNCH; i++) { P3SABallLauncher.launch(LaunchCallback); } int ballSaveTime = data.GetGameAttributeValue("MultiballBallSaveTime").ToInt(); if (ballSaveTime > 0) { PostModeEventToModes("Evt_BallSaveAdd", ballSaveTime); PostModeEventToModes("Evt_BallSavePauseUntilGrid", 0); } PostModeEventToModes("Evt_EnableBallSavedFeedback", false); PostModeEventToModes("Evt_RespawnEnable", false); PostModeEventToModes("Evt_EnableLanes", false); scoopIndex = random.Next(0, 6); p3.wallScoopMode.RaiseScoop(scoopIndex); isSuperJackpot = false; UpdateLEDs(); } public override void mode_stopped() { p3.wallScoopMode.LowerScoop(scoopIndex); RemovePlaylistRequests(); PostModeEventToModes("Evt_EnableBallSavedFeedback", true); PostModeEventToModes("Evt_RespawnEnable", true); PostModeEventToModes("Evt_EnableLanes", true); base.mode_stopped(); } private void UpdateLEDs() { for (int i = 0; i < 6; i++) { ushort[] color; if (i != scoopIndex) color = Multimorphic.P3.Colors.Color.blue; else if (isSuperJackpot) color = Multimorphic.P3.Colors.Color.red; else color = Multimorphic.P3.Colors.Color.yellow; LEDHelpers.OnLED(p3, LEDScriptsDict["scoop" + i], color); LEDHelpers.OnLED(p3, LEDScriptsDict["wall" + i], color); } } private bool TiltProcessEventHandler(string eventName, object eventData) { RemovePlaylistRequests(); return EVENT_CONTINUE; } private void LaunchCallback() { ballsInPlay++; pendingLaunches--; } private bool ScoopHitEventHandler(string evtName, object evtData) { // We need to account for possible grid inaccuracies. // We could check if the reported scoop is close to the raised scoop, // but we choose to accept all scoop hits instead. // int reportedScoop = (int)evtData; if (isSuperJackpot) { ScoreManager.Score(Scores.MULTIBALL_SUPER_JACKPOT); PlaySound("SuperJackpot"); PostModeEventToModes("Evt_BlinkPopup", "Super Jackpot"); } else { ScoreManager.Score(Scores.MULTIBALL_JACKPOT); PlaySound("Jackpot"); PostModeEventToModes("Evt_BlinkPopup", "Jackpot"); isSuperJackpot = true; UpdateLEDs(); } float superJackpotTimeout = data.GetGameAttributeValue("MultiballSuperJackpotTimeout").ToFloat(); delay("SuperJackpotTimer", NetProc.EventType.None, superJackpotTimeout, new Multimorphic.P3.VoidDelegateNoArgs(EndSuperJackpot)); return EVENT_CONTINUE; } private void EndSuperJackpot() { isSuperJackpot = false; UpdateLEDs(); } public bool sw_drain_active(Switch sw) { ballsInPlay--; if (pendingLaunches + ballsInPlay == 1) { PostModeEventToModes("Evt_MultiballEnded", 0); } return SWITCH_STOP; } } } |
That concludes our deep dive into the implementation of MultiballMode.
In P3SampleApp, MultiballMode has three more features:
Consult the MultiballMode source code for details.