628 lines
23 KiB
Markdown
628 lines
23 KiB
Markdown
---
|
|
prev: Zenject and SiraUtil
|
|
next: false
|
|
---
|
|
|
|
# Full Mod Guide
|
|
|
|
This part of the wiki will be dedicated to showing the full process of making a Beat Saber mod.
|
|
|
|
## The Mod
|
|
|
|
The first step of creating a mod is understanding exactly what you want to achieve.
|
|
|
|
In this tutorial, we will be creating a mod capable of changing the "MISS" effect and replacing it with text. The mod
|
|
will have an in-game interface to allow you to change the text through a text input. The mod will be designed in a
|
|
decoupled way, which will make it easier to add new features to the mod later if we wish.
|
|
|
|
We can use [BSML](./bsml.md) for the UI, and we can use [SiraUtil](./zenject.md) to create our custom text effects
|
|
while remaining loosely coupled to in-game functions.
|
|
|
|
### Creating The Project
|
|
|
|
The first thing we are going to do is set up the plugin template. Refer to the [setup guide](./setup.md) for more information.
|
|
We will name the plugin `MissTextChanger` and add dependencies to `BSML` and `SiraUtil` in the metadata.
|
|
|
|
This will start from a bare-bones BSIPA template, going step by step through the testing process of making a simple
|
|
plugin to help people understand everything. If you're following along, you can also just use the full template, which
|
|
has a basic SiraUtil and BSML setup already done.
|
|
|
|
### Figuring Out The Game
|
|
|
|
Before going any further, we need to get an understanding of how the game handles miss text normally. First, let's go in
|
|
to [ILSpy](./decompiling.md), and search for "ScoreController". This class is responsible for basically everything related
|
|
to giving the player score, so we can figure out how misses are handled from here.
|
|
|
|
In the `Start()` method of the `ScoreController`, we can see the `noteWasMissedEvent` being assigned to which is a part
|
|
of the `BeatmapObjectManager`. Let's analyze this event and see what the `add` method of the event is used by. We can now
|
|
see the `MissedNoteEffectSpawner` which, as we can assume by its name, is exactly what we're looking for.
|
|
|
|

|
|
|
|
Looking into the `MissedNoteEffectSpawner` we can see all it is doing is taking data from the missed note's `NoteController`
|
|
and passing it to a `FlyingSpriteSpawner` to spawn the effect. The sprite spawner manages a
|
|
[Zenject Pool](https://github.com/Mathijs-Bakker/Extenject/blob/master/Documentation/MemoryPools.md)
|
|
of sprite effects.
|
|
|
|
If we analyze the `FlyingSpriteEffect.Pool` we can figure out where it is bound by checking where it is used.
|
|
|
|

|
|
|
|
Now, looking at the `EffectPoolsManualInstaller.ManualInstallBindings()` method we see a couple different memory pools here.
|
|
One that is particularly interesting is the `FlyingTextEffect`, which if we analyze we can see the `FlyingTextSpawner`.
|
|
|
|
This is surely something we can use to achieve customizable miss text, however, looking at and comparing the spawn
|
|
methods for the sprite and text spawners, they are not exactly the same. The `x` of the `targetPos` vector is anchored
|
|
in the sprite spawner by its sign, which is why we see miss effects only fly to two locations to the left and right of
|
|
the track; there are only two possible values for sign.
|
|
|
|
Because of this difference, if we wanted to maintain the same visuals, we cannot use the `FlyingTextSpawner` for our needs.
|
|
We could use a harmony patch to change how the `SpawnFlyingSprite()` method works, but this may affect other mods that may
|
|
want to use this.
|
|
|
|
### The Solution
|
|
|
|
Instead of using the game's methods for our needs, let's make a custom effect spawner, and a custom flying object
|
|
effect. This should ensure that our mod doesn't conflict with other mods' features, but we're going to have to patch in to
|
|
the `MissedNoteEffectSpawner` to replace the base-game's miss effect with our custom one.
|
|
|
|
Let's start with the `MissTextEffect`, which will inherit `FlyingObjectEffect` like the other effects. For the text, we
|
|
will want a `TextMeshPro`.
|
|
|
|
```c#
|
|
internal class MissTextEffect : FlyingObjectEffect
|
|
{
|
|
// This is the pool from Zenject
|
|
public class Pool : MonoMemoryPool<MissTextEffect>;
|
|
|
|
// We don't have something to use here yet, we will get one later
|
|
private AnimationCurve fadeAnimationCurve;
|
|
|
|
// This field is serialized so that it will be included on instantiation
|
|
[SerializeField]
|
|
public TextMeshPro? textMesh;
|
|
|
|
private Color color;
|
|
|
|
public void InitAndPresent(string text, float duration, Vector3 targetPos,
|
|
Quaternion rotation, Color color, float fontSize, bool shake)
|
|
{
|
|
if (textMesh == null) return;
|
|
this.color = color;
|
|
textMesh.text = text;
|
|
textMesh.fontSize = fontSize;
|
|
InitAndPresent(duration, targetPos, rotation, shake);
|
|
}
|
|
|
|
public override void ManualUpdate(float t)
|
|
{
|
|
if (textMesh != null)
|
|
textMesh.color = color with { a = fadeAnimationCurve.Evaluate(t) };
|
|
}
|
|
}
|
|
```
|
|
|
|
We have some things to fill in on this object, but we will figure that out a bit later.
|
|
|
|
Next let's look at the spawner. We will be making sure to match the logic of the sprite spawner so
|
|
that the behaviour is the same.
|
|
|
|
```c#
|
|
internal class MissTextEffectSpawner : MonoBehaviour,
|
|
IFlyingObjectEffectDidFinishEvent
|
|
{
|
|
// There is a lot of data here that needs filling
|
|
private float duration;
|
|
private float xSpread;
|
|
private float targetYPos;
|
|
private float targetZPos;
|
|
private Color color;
|
|
private float fontSize;
|
|
private MissTextEffect.Pool missTextEffectPool;
|
|
|
|
public void SpawnText(
|
|
Vector3 pos, Quaternion rotation, Quaternion inverseRotation)
|
|
{
|
|
var text = "CUSTOM MISS";
|
|
var targetPos = rotation * new Vector3(
|
|
Mathf.Sign((inverseRotation * pos).x) * xSpread,
|
|
targetYPos,
|
|
targetZPos);
|
|
|
|
var missTextEffect = missTextEffectPool.Spawn();
|
|
missTextEffect.didFinishEvent.Add(this);
|
|
missTextEffect.transform.localPosition = pos;
|
|
missTextEffect.InitAndPresent(
|
|
text, duration, targetPos, rotation, color, fontSize, false);
|
|
}
|
|
|
|
public void HandleFlyingObjectEffectDidFinish(
|
|
FlyingObjectEffect flyingObjectEffect)
|
|
{
|
|
flyingObjectEffect.didFinishEvent.Remove(this);
|
|
missTextEffectPool.Despawn((MissTextEffect)flyingObjectEffect);
|
|
}
|
|
}
|
|
```
|
|
|
|
All we need to do is register these components in an installer. Let's create a `PlayerInstaller` and
|
|
add our bindings.
|
|
|
|
- Bind the `MissTextEffectSpawner` as a component on a single new game object
|
|
- Bind the memory pool for the `MissTextEffect` similar to the other score effects in the
|
|
`EffectPoolsManualInstaller`
|
|
|
|
```c#
|
|
internal class PlayerInstaller : Installer
|
|
{
|
|
public override void InstallBindings()
|
|
{
|
|
Container.Bind<MissTextEffectSpawner>()
|
|
.FromNewComponentOnNewGameObject()
|
|
.AsSingle();
|
|
Container.BindMemoryPool<MissTextEffect, MissTextEffect.Pool>()
|
|
.WithInitialSize(20)
|
|
.FromComponentInNewPrefab(GetMissTextEffectPrefab());
|
|
}
|
|
|
|
private static MissTextEffect GetMissTextEffectPrefab()
|
|
{
|
|
var prefabObject = new GameObject("MissTextEffect");
|
|
var textEffect = prefabObject.AddComponent<MissTextEffect>();
|
|
|
|
var textObject = new GameObject("Text") { layer = 5 };
|
|
textObject.transform.SetParent(prefabObject.transform, false);
|
|
|
|
textEffect.textMesh = textObject.AddComponent<TextMeshPro>();
|
|
textEffect.textMesh.alignment = TextAlignmentOptions.Capline;
|
|
textEffect.textMesh.fontStyle = FontStyles.Bold | FontStyles.Italic;
|
|
|
|
return textEffect;
|
|
}
|
|
}
|
|
```
|
|
|
|
Creating the `MissTextEffect` prefab here doesn't make much sense and should realistically move to its
|
|
own class but for now this is fine to demonstrate what we're doing.
|
|
|
|
Remember to add the zenjector to the `Plugin` init too.
|
|
|
|
```c#
|
|
[Plugin(RuntimeOptions.SingleStartInit), NoEnableDisable]
|
|
internal class Plugin
|
|
{
|
|
[Init]
|
|
public Plugin(Logger log, Config config,
|
|
PluginMetadata metadata, Zenjector zenjector)
|
|
{
|
|
log.Info($"{metadata.Name} {metadata.HVersion} initialized.");
|
|
|
|
zenjector.UseLogger(log);
|
|
zenjector.Install<PlayerInstaller>(Location.Player);
|
|
}
|
|
}
|
|
```
|
|
|
|
Now that we have the main components of the mod outlined, we need to set their fields. There are two ways
|
|
we can do this. We can do it manually by loading up a map in-game, opening [Runtime Unity Editor](rue.md),
|
|
and looking for the miss effect spawner to see the values. This may work, but we should figure out how to automate
|
|
it in case the values aren't constant.
|
|
|
|
As seen before, we found the prefab for the `FlyingSpriteEffect` in the `EffectPoolsManualInstaller`. This isn't
|
|
actually an installer, instead it's a part of the _much_ larger `GameplayCoreInstaller`.
|
|
|
|
If we were to patch in to the `GameplayCoreInstaller`, we can access the prefabs for the `FlyingTextEffect` and
|
|
the instance of the `FlyingSpriteSpawner` to get the fields we need for our custom components.
|
|
|
|
Since we're using SiraUtil for this mod, let's make an [affinity patch](./zenject.md#affinity-patching)
|
|
into the `InstallBindings()` method. We can take the fields from the prefabs and bind their values with an ID,
|
|
so that we can inject them into our own components.
|
|
|
|
```c#
|
|
internal class GameCoreInstallerHook : IAffinity
|
|
{
|
|
[AffinityPrefix]
|
|
[AffinityPatch(typeof(GameplayCoreInstaller), "InstallBindings")]
|
|
private void InstallBindingsPostfix(GameplayCoreInstaller __instance)
|
|
{
|
|
var container = __instance.Container;
|
|
var flyingSpriteSpawner = __instance._missedNoteEffectSpawnerPrefab._missedNoteFlyingSpriteSpawner;
|
|
var flyingTextEffect = __instance._effectPoolsManualInstaller._flyingTextEffectPrefab;
|
|
|
|
float duration = flyingSpriteSpawner._duration;
|
|
float spread = flyingSpriteSpawner._xSpread;
|
|
float targetYPos = flyingSpriteSpawner._targetYPos;
|
|
float targetZPos = flyingSpriteSpawner._targetZPos;
|
|
var color = Color.white;
|
|
const float fontSize = 4.5f; // Miss text is a sprite; estimate the font size
|
|
var fadeAnimationCurve = flyingTextEffect._fadeAnimationCurve;
|
|
var moveAnimationCurve = flyingTextEffect._moveAnimationCurve;
|
|
|
|
container.BindInstance(duration).WithId("missEffectDuration").AsCached();
|
|
container.BindInstance(spread).WithId("missEffectSpread").AsCached();
|
|
container.BindInstance(targetYPos).WithId("missEffectTargetYPos").AsCached();
|
|
container.BindInstance(targetZPos).WithId("missEffectTargetZPos").AsCached();
|
|
container.BindInstance(color).WithId("missEffectColor").AsCached();
|
|
container.BindInstance(fontSize).WithId("missEffectFontSize").AsCached();
|
|
container.BindInstance(fadeAnimationCurve).WithId("textEffectFadeAnimationCurve").AsCached();
|
|
container.BindInstance(moveAnimationCurve).WithId("textEffectMoveAnimationCurve").AsCached();
|
|
}
|
|
}
|
|
```
|
|
|
|
Make sure not to forget to bind this patch. Since we're patching the installer itself, binding it alongside
|
|
the installer we are patching won't work because the `InstallBindings` will be called before our patch is applied.
|
|
Instead let's make an `AppInstaller`, because that will be applied when the game initializes.
|
|
|
|
```c#
|
|
internal class AppInstaller : Installer
|
|
{
|
|
public override void InstallBindings()
|
|
{
|
|
Container.BindInterfacesTo<GameCoreInstallerHook>().AsSingle();
|
|
}
|
|
}
|
|
```
|
|
|
|
Remember to add this to the `Plugin` init too.
|
|
|
|
```c#
|
|
zenjector.Install<AppInstaller>(Location.App);
|
|
```
|
|
|
|
And now we add [inject methods](./zenject.md#methods) to our components, starting with the `MissTextEffect`. Note that
|
|
the `_moveAnimationCurve` is part of the base class. We need this so that the movement animation matches the base game's
|
|
movement.
|
|
|
|
```c#
|
|
[Inject]
|
|
public void Init(
|
|
[Inject(Id = "textEffectFadeAnimationCurve")] AnimationCurve fadeAnimationCurve,
|
|
[Inject(Id = "textEffectMoveAnimationCurve")] AnimationCurve moveAnimationCurve)
|
|
{
|
|
this.fadeAnimationCurve = fadeAnimationCurve;
|
|
_moveAnimationCurve = moveAnimationCurve;
|
|
}
|
|
```
|
|
|
|
And for `MissTextEffectSpawner` there are quite a few properties. Also, remember to inject the `Pool`.
|
|
|
|
```c#
|
|
[Inject]
|
|
public void Init(
|
|
[Inject(Id = "missEffectDuration")] float duration,
|
|
[Inject(Id = "missEffectSpread")] float xSpread,
|
|
[Inject(Id = "missEffectTargetYPos")] float targetYPos,
|
|
[Inject(Id = "missEffectTargetZPos")] float targetZPos,
|
|
[Inject(Id = "missEffectColor")] Color color,
|
|
[Inject(Id = "missEffectFontSize")] float fontSize,
|
|
MissTextEffect.Pool missTextEffectPool)
|
|
{
|
|
this.duration = duration;
|
|
this.xSpread = xSpread;
|
|
this.targetYPos = targetYPos;
|
|
this.targetZPos = targetZPos;
|
|
this.color = color;
|
|
this.fontSize = fontSize;
|
|
this.missTextEffectPool = missTextEffectPool;
|
|
}
|
|
```
|
|
|
|
Now we're all set up to implement our custom text effect. We just need to figure out how to spawn them. Ultimately,
|
|
the goal is to replace the game's "MISS" sprite effect with our own, so let's go back to the `MissedNoteEffectSpawner`
|
|
and patch it to replace the `FlyingSpriteSpawner` with our spawner by using a patch.
|
|
|
|
By using an affinity patch we can inject the `MissTextEffectSpawner` and use it within the patch with ease.
|
|
|
|
```c#
|
|
internal class OnMissEffectPatch : IAffinity
|
|
{
|
|
private readonly MissTextEffectSpawner missTextEffectSpawner;
|
|
|
|
public OnMissEffectPatch(MissTextEffectSpawner missTextEffectSpawner)
|
|
{
|
|
this.missTextEffectSpawner = missTextEffectSpawner;
|
|
}
|
|
|
|
[AffinityPrefix]
|
|
[AffinityPatch(typeof(MissedNoteEffectSpawner), nameof(MissedNoteEffectSpawner.HandleNoteWasMissed))]
|
|
private bool HandleNoteWasMissedPrefix(MissedNoteEffectSpawner __instance, NoteController noteController)
|
|
{
|
|
if (noteController.hidden
|
|
|| noteController.noteData.time + 0.5f < __instance._audioTimeSyncController.songTime
|
|
|| noteController.noteData.colorType == ColorType.None)
|
|
{
|
|
// Do nothing
|
|
return false;
|
|
}
|
|
|
|
var position = noteController.inverseWorldRotation * noteController.noteTransform.position;
|
|
position.z = __instance._spawnPosZ;
|
|
|
|
// Spawn our miss text effect
|
|
missTextEffectSpawner.SpawnText(
|
|
noteController.worldRotation * position,
|
|
noteController.worldRotation,
|
|
noteController.inverseWorldRotation);
|
|
|
|
// Cancel the original implementation
|
|
return false;
|
|
}
|
|
}
|
|
```
|
|
|
|
Apart from being syntactically different to the original method we can see from the decompiler, the logic is the same.
|
|
We can bind this in the `PlayerInstaller` because this method runs during gameplay, and that's where our effect spawner
|
|
is bound too.
|
|
|
|
```c#
|
|
Container.BindInterfacesTo<OnMissEffectPatch>().AsSingle();
|
|
```
|
|
|
|
### Testing
|
|
|
|
At this point we should be able to see this in action. Open the game with [FPFC](./index.md#launch-args) and open any map.
|
|
Using No Fail will help.
|
|
|
|

|
|
|
|
## Adding Settings
|
|
|
|
There are many ways to add interactive menus in to the game, which you can see in the [UI section of this wiki](./bsml.md#adding-menus).
|
|
|
|
For this guide we will be using a [custom flow coordinator](./bsml.md#custom-flow-coordinator) which will provide plenty
|
|
of space to add more features to the UI in the future if we need to.
|
|
|
|
Before creating the UI, let's decide what features we need it to have.
|
|
|
|
- We want a setting to toggle the mod off and on - this is for the player's convenience and most mods should have one
|
|
- We need a way to input text to change the miss text, we can use the [ModalKeyboard](https://monkeymanboy.github.io/BSML-Docs/Tags/ModalKeyboardTag/)
|
|
for this
|
|
- As well as the input, we should also have some [Text](https://monkeymanboy.github.io/BSML-Docs/Tags/TextTag/) to show
|
|
the current miss text
|
|
- And finally, we need a way to open the modal keyboard. A simple [Button](https://monkeymanboy.github.io/BSML-Docs/Tags/ButtonTag/)
|
|
can do this
|
|
|
|
### Creating A Config
|
|
|
|
To make settings that will save between sessions, we can utilize BSIPA's config. Let's create a config class, and
|
|
add it to the plugin init. Instead of making a static config, we should pass it as a param of the `AppInstaller`, then bind
|
|
it there so we can inject it anywhere.
|
|
|
|
```c#
|
|
[assembly: InternalsVisibleTo(GeneratedStore.AssemblyVisibilityTarget)]
|
|
namespace MissTextChanger;
|
|
|
|
internal class PluginConfig
|
|
{
|
|
public virtual bool Enabled { get; set; } = true;
|
|
public virtual string MissText { get; set; } = "MISS";
|
|
}
|
|
```
|
|
|
|
Then add it to the `Plugin` init:
|
|
|
|
```c#
|
|
var pluginConfig = config.Generated<PluginConfig>();
|
|
|
|
zenjector.Install<AppInstaller>(Location.App, pluginConfig);
|
|
```
|
|
|
|
And in the installer:
|
|
|
|
```c#
|
|
internal class AppInstaller : Installer
|
|
{
|
|
public AppInstaller(PluginConfig pluginConfig)
|
|
{
|
|
this.pluginConfig = pluginConfig;
|
|
}
|
|
|
|
public override void InstallBindings()
|
|
{
|
|
Container.BindInstance(pluginConfig).AsSingle();
|
|
/* ... */
|
|
```
|
|
|
|
### Implementing The Settings
|
|
|
|
Before we mess around with the UI, let's make sure we can make these new features work. First, inject the config
|
|
into the `PlayerInstaller` so we can use it to stop our bindings from being made:
|
|
|
|
```c#
|
|
internal class PlayerInstaller : Installer
|
|
{
|
|
private readonly PluginConfig pluginConfig;
|
|
|
|
public PlayerInstaller(PluginConfig pluginConfig)
|
|
{
|
|
this.pluginConfig = pluginConfig;
|
|
}
|
|
|
|
public override void InstallBindings()
|
|
{
|
|
if (!pluginConfig.Enabled) return;
|
|
/* ... */
|
|
```
|
|
|
|
You can go to the config `.json` file in the `UserData` folder and tweak the settings manually to test that this
|
|
is working. Next, let's add the config to the `MissTextEffectSpawner` and use the text property in the
|
|
`SpawnText()` method.
|
|
|
|
```c#
|
|
public void SpawnText(
|
|
Vector3 pos, Quaternion rotation, Quaternion inverseRotation)
|
|
{
|
|
/* ... */
|
|
var text = pluginConfig.MissText;
|
|
missTextEffect.InitAndPresent(text, duration, targetPos, rotation,
|
|
color, fontSize, false);
|
|
/* ... */
|
|
}
|
|
```
|
|
|
|
That was simple thanks to zenject. Now let's move on to setting up the UI.
|
|
|
|
### Adding The UI
|
|
|
|
Now we will set up the flow coordinator so that we can start playing around with the BSML immediately.
|
|
Let's start at the end of the dependency tree with the view controller.
|
|
|
|
```xml
|
|
<vertical>
|
|
<toggle-setting value="Enabled" text="Enabled" apply-on-change="true"/>
|
|
<text text="~KeyboardInput" id="MissText" align="Capline" font-size="8"
|
|
italics="true" bold="true"/>
|
|
<button click-event="ShowInputKeyboard" text="change..."
|
|
pref-height="10" pref-width="27"/>
|
|
</vertical>
|
|
<modal-keyboard value="KeyboardInput" show-event="ShowInputKeyboard"/>
|
|
```
|
|
|
|
And the host for our view:
|
|
|
|
```c#
|
|
[HotReload(RelativePathToLayout = @".\settingsView.bsml")]
|
|
[ViewDefinition("MissTextChanger.Menu.settingsView.bsml")]
|
|
internal class SettingsViewController : BSMLAutomaticViewController
|
|
{
|
|
[Inject] private readonly PluginConfig pluginConfig = null!;
|
|
|
|
[UIComponent("MissText")] private readonly TextMeshProUGUI missText = null!;
|
|
|
|
[UIAction("#post-parse")]
|
|
private void PostParse()
|
|
{
|
|
SetMissTextPreview(pluginConfig.MissText);
|
|
}
|
|
|
|
private bool Enabled
|
|
{
|
|
get => pluginConfig.Enabled;
|
|
set => pluginConfig.Enabled = value;
|
|
}
|
|
|
|
private string KeyboardInput
|
|
{
|
|
get => pluginConfig.MissText;
|
|
set
|
|
{
|
|
pluginConfig.MissText = value;
|
|
SetMissTextPreview(value);
|
|
}
|
|
}
|
|
|
|
private void SetMissTextPreview(string v) =>
|
|
missText.text = string.IsNullOrEmpty(v) ? "<alpha=#AA>No miss text" : v;
|
|
}
|
|
```
|
|
|
|
By using the `HotReload` attribute we can get live updates to the view controller when we change the bsml file,
|
|
without having to re-build the mod.
|
|
|
|
Now for the `FlowCoordinator`, which is responsible for managing our view controller.
|
|
|
|
```c#
|
|
internal class MissTextChangerFlowCoordinator : FlowCoordinator
|
|
{
|
|
[Inject] private readonly SettingsViewController settingsViewController = null!;
|
|
|
|
public event Action? DidFinish;
|
|
|
|
protected override void DidActivate(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling)
|
|
{
|
|
if (firstActivation)
|
|
{
|
|
showBackButton = true;
|
|
SetTitle("MissTextChanger");
|
|
}
|
|
|
|
if (addedToHierarchy)
|
|
{
|
|
ProvideInitialViewControllers(settingsViewController);
|
|
}
|
|
}
|
|
|
|
protected override void BackButtonWasPressed(ViewController topViewController)
|
|
{
|
|
DidFinish?.Invoke();
|
|
}
|
|
}
|
|
```
|
|
|
|
We're using an action here to signal when we are done so that we don't need an extra dependency to handle returning to the
|
|
main flow coordinator. This event will be used by our button manager.
|
|
|
|
```c#
|
|
internal class MenuButtonManager : IInitializable, IDisposable
|
|
{
|
|
private readonly MainFlowCoordinator mainFlowCoordinator;
|
|
private readonly MissTextChangerFlowCoordinator missTextChangerFlowCoordinator;
|
|
private readonly MenuButtons menuButtons;
|
|
private readonly MenuButton menuButton;
|
|
|
|
public MenuButtonManager(
|
|
MainFlowCoordinator mainFlowCoordinator,
|
|
MissTextChangerFlowCoordinator missTextChangerFlowCoordinator,
|
|
MenuButtons menuButtons)
|
|
{
|
|
this.mainFlowCoordinator = mainFlowCoordinator;
|
|
this.missTextChangerFlowCoordinator = missTextChangerFlowCoordinator;
|
|
this.menuButtons = menuButtons;
|
|
menuButton = new("MissTextChanger", PresentFlowCoordinator);
|
|
}
|
|
|
|
public void Initialize()
|
|
{
|
|
menuButtons.RegisterButton(menuButton);
|
|
missTextChangerFlowCoordinator.DidFinish += DismissFlowCoordinator;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
missTextChangerFlowCoordinator.DidFinish -= DismissFlowCoordinator;
|
|
}
|
|
|
|
private void PresentFlowCoordinator() =>
|
|
mainFlowCoordinator.PresentFlowCoordinator(missTextChangerFlowCoordinator);
|
|
|
|
private void DismissFlowCoordinator() =>
|
|
mainFlowCoordinator.DismissFlowCoordinator(missTextChangerFlowCoordinator);
|
|
}
|
|
```
|
|
|
|
That's everything we need to create our UI. Now we just need a `MenuInstaller` to create the bindings.
|
|
|
|
```c#
|
|
internal class MenuInstaller : Installer
|
|
{
|
|
public override void InstallBindings()
|
|
{
|
|
Container.BindInterfacesTo<MenuButtonManager>().AsSingle();
|
|
Container.Bind<MissTextChangerFlowCoordinator>().FromNewComponentOnNewGameObject().AsSingle();
|
|
Container.Bind<SettingsViewController>().FromNewComponentAsViewController().AsSingle();
|
|
}
|
|
}
|
|
```
|
|
|
|
And of course, remember to add this to the `Plugin` init.
|
|
|
|
```c#
|
|
zenjector.Install<MenuInstaller>(Location.Menu);
|
|
```
|
|
|
|

|
|
|
|
## Closing Remarks
|
|
|
|
We have now covered every step of creating a new Beat Saber mod.
|
|
|
|
This example mod has been designed in a way which allows easy changes and extension to its features. When designing a mod,
|
|
it's important to figure out what you want to do so that development doesn't reach a halt.
|
|
|
|
If you want to learn more we highly recommend checking the source code for other mods to learn more about different APIs
|
|
and how Beat Saber works. You can find that most mods are open source, and you can find that source by visiting
|
|
[BeatMods](https://beatmods.com/) and going to the more info section for any given mod.
|
|
|
|
You can view all of the source code used in this guide [here](https://github.com/qqrz997/TutorialPCMod).
|