見出し画像

ChroMapperプラグインの創りかた

BeatSaber AdventCalendar 2024/12/10

本記事は Beat Saber Advent Calendar 2024 の10日目の記事になります。

こんにちは、リュナンです。
今年のアドカレの記事は、マッピングツールのChroMapperのプラグインを作る記事を書いて行こうと思います。

正直、いままでの5年間の全アドカレの中で一番難しくて、対象者が限定されそうな記事になると思います。あと非常に長いです😅
ただ、ChroMapperのプラグイン作りは、BeatSaberのmod作りに非常に似ています。特にChroMapper本体がオープンソースなので理解がしやすいです。BeatSaberのmod作りに興味がある人にも良い参考になると思います。

今回の記事は以下の順番に解説します。最初の方はプログラミング経験がなくても分かる内容で、下に行くほど難しくなります。

  • 必要なツールのインストール方法

  • ChroMapperのビルド方法

  • ChroMapperのUnityエディタ上での改造方法

  • 改造した内容をプラグインで実現する方法

ChroMapperのビルド方法は、任意のバージョンのChroMapperが使えるようになりますので、誤ってバージョンアップした場合や、古いプラグインを使いたい場合などに参考になります。

Unityエディタ上での改造からプログラミングの話になります。C#の知識や経験は無くても構いませんが、Pythonとか何でも良いのでプログラミング経験は必要です。記事の後半ほど難しくなると思うので、わからなかったら途中で離脱してください。

さて今回プラグインを作るにあたり、ChroMapperにどんな機能を設けるのかですが、規模が大きいと説明が大変ですし、役立たたない機能だと面白くありません。どうしようか考えていたら、ちょうど良いタイミングでGe2toroさんがX(Twitter)でポストしていました。

つまりこんな感じで、カーソル移動単位の枠をもう一つ追加します

見た感じ簡単?にできそうなので、今回はこの機能を実現する内容にします。ただし、見た目と実現の大変さは比例しないので、簡単そうに見えて大変だったとか普通にあります。(逆もある)

なお、本記事で作成したプラグインは以下で公開しています。
ChroMapper-PrecisionStepAdditions
(本記事をもう少し拡張して3~5個目まで追加できるようになっています)

それでは始めて行きましょう。


必要なツール

今回の記事でインストールが必要なツールは以下のとおりです。

ChroMapper

ChroMapperはプラグインの動作確認に使います。この記事に興味がある人は既にインストール済みだと思うので説明は省略します。現在の最新版Ver 0.10.779を前提として説明します。

Git for Windows

Git for WindowsはChroMapperのUnityプロジェクトを開いたりビルドするのに必要です。公式サイトからダウンロードしてインストールします。
インストール時に設定をいろいろ聞かれますが、基本的に全てデフォルトでOKです。オプションの詳しい説明は検索して調べてください。

ただし、普通にインストールすると、右クリックのメニューにOpen Git GUI/Bash hare が追加されます。

Git for Windowsをデフォルトでインストールする追加される

このままでも問題ないのですが、使用頻度が低くスタートメニューから起動が可能なので、不要ならインストール最初の以下のオプションを外してください。

Gitはバージョン(変更履歴)管理ツールなので、プログラミングするときは、個人開発でも使えるようになったほうが便利です。あと、作ったものをGitHubで公開するなら必須になります。ChroMapperのUnityプロジェクトはGitを使用するパッケージを使用しているので、今回の記事ではUnityで開いてビルドするためだけに入れます。

Unity Hub

Unity Hubも利用者は多いと思うので簡単に、ChroMapperのプロジェクトを開いて中身を確認したり、ChroMapperをビルドするのに使用します。
公式サイトからダウンロードしてインストールしてください。使用するにはUnity IDが必要なのでアカウントを作成してください。

Visual Studio Community 2022

ChroMapperをUnityでビルドするだけなら不要ですが、C#スクリプトの編集に便利なのと、ChroMapperのプラグインをビルドするのに必要です。
公式サイトから個人利用が無料なCommunity 2022をダウンロードしてインストールします。
Unity HubでUnityエディタをインストールするときにモジュールの開発者ツールでVisual Studio Communityがありますが、2019とか古い場合が多いのでUnityのインストールではチェックを外して、Microsoftの公式からインストールしてください。

Visual Studioのインストールでは、Unityによるゲーム開発と個別のコンポーネントで.NET Framework 4.8 Targeting Packを最低限チェックしてください。

「Unityによるゲーム開発」と「.NET Framework 4.8 Targeting Pack」を選択

インストールの詳細が最低限以下のようになっているか確認してください。
Unity Hubは付属Verは古いのでチェックを外してください。

最低限必要なオプション

ChroMapperのビルド方法

プロジェクトの入手と追加

まずはChroMapperのUnityプロジェクトを入手します。
分かる人ならChroMapperのGitHubからGitでCloneですが、今回は誰でも分かる方法で説明します。
まずは以下のリンクから0.10.779のリリースページを開きます。
Release  ChroMapper 0.10.779
そして、一番下のAssetsの Source code (zip)をダウンロードします。

ChroMapperプロジェクトのダウンロード

ダウンロードしたファイルを解凍して、Unity Hubのプロジェクトに追加します。

ChroMapperプロジェクトの追加

対象VerのUnityのインストール

Unity 2021.3.32f1がインストールされていない場合は警告表示が出ますので、2021.3.32f1をインストールしてください。

インストール時にはモジュール選択画面で開発ツールのMicrosoft Visual Studio Community 2019は必ずチェックを外してください。基本的にモジュールは全てチェック不要です。

プロジェクトを開く時に、初回だけ以下のような「開こうとしているプロジェクトにはコンパイルエラーがある」のような内容のメッセージが表示されます。ここは Ignore で無視して進めてください。

ちなみに、以下の画像のような Error when executing git command. エラーが表示される場合はGit for Windowsがインストールされていないか、正しくインストールされていないのでQuitして確認をしてください。Continueして進めてもビルドがエラーになります。

ChroMapperのビルド

プロジェクトが開いたら、メニューのFileからBuid Settings…を選択します

Build Settingsの設定は何も変更せずに、そのままBuildをクリックします。

ビルドしたChroMapperの保存先を選択します。適当に空のフォルダを作成して選択してください。

ビルドの途中で、毎回「あなたはプロジェクトメンバーではないので、Unityサービスにアクセスできません。続行しますか?」とメッセージが表示されます。公式のChroMapper開発で使用しているUnityサービスの関係で出るものなので、無視してYesでビルドを進めます。

毎回表示されるけどYesで進める

ビルドが終わると、先程選択したフォルダにChroMapperの実行ファイル一式が生成されますので、ChroMapper.exeを実行して起動するか確認してください。

これでChroMapperのビルド方法は終わりです。古いバージョンのChroMapperが欲しい場合は、リリースページの欲しいバージョンのAssetsからプロジェクトを入手してビルドすればOKです。

Unityエディタ上での改造方法

まずは、ChroMapperで使われているシーンの説明です。
シーンとは、UnityのHierarchy(左側のオブジェクトツリー)に並んでいるオブジェクトの集まりを一つにまとめたもので、場面ごとに切り替えて使います。BeatSaberだとメニュー画面とプレイするシーンは別になります。
ChroMapperで使われているシーンは、先程のBuild Settingsの画面に表示されていた、以下の一覧がそうです。

ビルド画面のシーン一覧

左端のチェックがされているものが使用しているものです。右端の数字はbuildIndexで、後でプラグイン作成時に使うので覚えておいてください。
シーンの名前を見るとだいたい想像つくと思いますが、ChroMapperではこの5つのシーンを切り替えて使っています。

シーンのファイルはProjectのAseetsの__Scenesフォルダに入っています。
この中で、今回の改造対象のシーンはマッピング編集画面なので、03_Mapperです、ダブルクリックして開いてみます。

※右下のバーを一番左にして一覧表示にしています

カメラを移動させていない初期状態で開くと、以下のような見慣れたマッピングのレーン表示が開くと思います。

なんか見慣れが画面が

さて、今回編集したいのはUIの部分です。どこにあるのでしょう?
少しマウスのホイールを手前に回してカメラを引いていくと、なんか巨大な枠とバーっぽいものが見えます。

巨大な枠の端とバーみたいなのが

そのままカメラを引いていくと、超巨大なUIが出てきました。

超巨大なUI

UnityのUIは、表示するデバイスの画面に合わせて自動調整されるので、他のオブジェクトのサイズとは全く異なる管理になっていて、Unityのエディタ上ではこのように超巨大になっています。

よく見ると、UIが裏から見た表示になっていますので、右上のシーンギズモのZの反対側をクリックすると、UIを正面から見られるようになります。

シーンギズモ

その状態で、シーンギズモの真ん中のブロックをクリックすると、平行投影モードになって、UI画面の編集がしやすくなります。

平行投影モードの表示(グリッドが正方形になる)

今回の改造対象のカーソル移動単位のUI表示を拡大して、対象のゲームオブジェクトがHierarchyのどこか探すため、下側の[ 1/1 ]の枠を17回ぐらいクリックすると、[ 1/1 ]の枠が選択状態になって、Cursor Interval の下にあるSecond Intervalの中のオブジェクトが選択されているのが分かります。

対象オブジェクトの探し方

ChroMapperの上部中央にあるアイコンパネルはCenter Panelというオブジェクト名になっていて、その下のItemsの中にカーソル移動単位や配置・ライティングモード切り替えなどの機能が並んでいます。
今回対象のカーソル移動単位の機能は、Cursor Intervalになります。

Cursor IntervalのInspectorを見ると、いくつかコンポーネントがあります。

Cursor IntervalのInspector

Rect TransformはUI用のTransformで、UIオブジェクトの位置を設定するのに使います。
Vertical Layout Groupは、子のUIオブジェクトの位置(Rect Transform)を自動調整するためのコンポーネントです。
Precision Step Dispay Controller (Script)が、今回のカーソル移動単位を設定するスクリプト本体になります。

UIオブジェクト追加

さて、まずはカーソル移動単位のUIオブジェクトを追加します。
Cursor Intervalの子オブジェクトを見ると、First Interval と Second Intervalのオブジェクトがあります。これをコピーしてThird Intervalを追加すれば良さそうです。実際それでOKです。
Second Intervalを右クリックして、Copyしてから再度Second Intervalを右クリックしてPasteします。そうすると、同じ階層でSecond Intervalがコピーされるので、名前をThird Intervalに変更します。

Second IntervalをコピーしてThird Intervalを作る

画面の方も見てみると、良い感じに3つ目の入力欄が追加されています。
この良い感じに追加してくれるのは、Cursor IntervalにあったVertical Layout Groupのおかげで、垂直方向に自動的に並べてくれるのです。

良い感じに3つ目が追加される

さて、見た目の変更は終わりで、ここから先はスクリプトの話になります。
プログラミングさっぱりの人はここが第一の離脱点かもしれません。
だけど、なるべく最初は簡単に説明するので、分かる範囲でもう少しお付き合い下さい。

でも、オフチョベットしたテフをマブガッドしてリットするにしか見えないなら、無理せず離脱してくださいね。

スクリプトの説明

Cursor IntervalのPrecision Step Display Controller (Script)を見ると、DisplayとSecond Displayに1個目と2個目のUIオブジェクトが、First OutlineとSecond Outlineに、それぞれの子のBackgroundオブジェクトが設定されています。
まずは、ここに3個目の設定項目追加が必要です。

DisplayとOutlineの設定項目

ちなみにOutlineとは何かと言うと、入力枠の周りのことです。
選択状態だと白、未選択だと濃い灰色になります。

Outlineの状態

では、スクリプトを開いてみます。
Script の PrecisionStepDisplayControllerをダブルクリックすると、Visual Studioが開きます。そして、こんな感じのコードが表示されます。

PrecisionStepDisplayControllerのコード

コードを見てると慣れてないと分かりづらいので、機能をブロック図で解説すると以下のような動きになっています。

カーソル移動単位のUIの処理だけですが、メソッドがいっぱいあって複雑な感じがします。でも、xキーで切り替える、マウスで選択する、値を変更する、起動時に初期値を設定するなど、各イベントを考えると無駄な処理がなくシンプルにまとまってると思います。

この中で、On Select(String)とOn End Edit(String)はコードの中に出てきていません。どこから呼ばれているかと言うと、First/Second IntervalのInspectorのTextMeshPro - Input Fieldにあります。

マウスのクリックや値変更のイベント設定

On End Editは値入力終了後に呼び出されるイベントです。
On SelectはUI選択時に呼び出されるイベントです。
各イベントでは呼び出すスクリプトのメソッドが登録されています。
SelectSnapの引数はbool型のためチェックボックスが表示されています。First IntervalはTrueでチェックされていて、Second IntervalはFalseでチェックがありません。

あと、On Select と On DeselctにPrecisionStepDisplayControllerのOnSelectとOnDeselectがありますが、こちらはPrecisionStepDisplayControllerが継承しているクラスのDisableActionsFieldにあるメソッドが呼び出されています。
また、キーボードでxを押したときの処理のSwapSelectedIntervalは、参照先をクリックするとKeybindUpdateUIControllerから呼び出されていることが分かります。
これらは、今回は改造の対象外なので説明はしませんが、気になる方はコードを追って処理を見てみて下さい。

改造のポイント

今回、Third Intervalを追加するにあたって変更する内容は以下の通りです。

  • Startメソッドで、Third Intervalの設定値復元の追加

  • UpdateTextメソッドで、Third Intervalの設定値保存と表示更新の追加

  • SelectSnapメソッドで、Third IntervalのUI選択状態の表示更新、UpdateManualPrecisionStepを呼び出す引数のThird Interval値の対応

  • UI選択状態を表すfirstAcrive変数のThird Intervalの対応

この中で結構厄介なのが、最後のfirstActive変数の対応です。UIの選択状態を保持している変数ですが、bool型なのでFirst Intervalの選択中か、それ以外(つまりSecond Intervalが選択中)しか対応していません。
bool型ではなくint型など3つ以上の状態を保持する改造が必要です。
これが後々プラグイン化するときに面倒なことになるのですが、それはまた後で説明します。まずは、素直に改造していきます。

素直な改造(メンバ変数)

まずは、Inspector上でThird IntervalのUIオブジェクトとOutlineの設定が必要なので、メンバ変数にthirdDisplayとthirdOutlineの変数を追加します。変数名以外はsecondと同じなので、コピーして変数名を変更します。

通常、Unityのスクリプトはアクセス修飾子publicのメンバ変数だと自動でInspector上に設定欄が出てきますが、privateの変数の場合は頭に[SerializeField]と属性を付けることで表示されるようになります。

thirdDisplay と thirdOutlineの設定欄追加

この状態でスクリプトを保存すると、Inspectorが更新されて設定欄が追加されるので、Third Intervalのオブジェクトと、その子のBackgroundを設定します。ちなみに、スクリプトにエラーが有る状態だと更新されないので注意して下さい。

Third Intervalと子のBackgroundのオブジェクトを設定

次にfirstActive変数の変更です。int型に変更すると、変数の名前が実態と合わなくなるので、activeStepと言う名前に変更します。(名前は何でも良い)
この変数は0のときにFirst, 1のときにSecond, 2のときにThirdを表すことにします。

選択対象を保持する変数を変更

素直な改造(Startメソッド)

次にStartメソッドの変更です。
ゲームオブジェクトにコンポーネントとして追加しているスクリプトは、フレーム毎にUpdateメソッドで処理をさせることができます。Startメソッドは、そのUpdateメソッドが最初に呼ばれる直前に1回実行されます。つまり初期化処理などをここで行います。

初期化処理としてエディタ起動時にThird Intervalの設定値を復元するため、保存していた設定値をthirdDisplay.textに代入するのですが、Third Interval用設定のSettings.Instance.CursorPrecisionCは無いので、エラーになります。
設定値の変数を追加するため、Settings.Instance.CursorPrecisionBにカーソルを当てて、出てきたツールチップからSettings.CursorPrecisionBを開きます。

ツールチップからSettings.CursorPrecisionBを開く

そして、CursorPrecisionCをメンバ変数として追加して、Settings.csを保存します。これで、エラーは消えると思います。

SettingsにCursorPrecisionCのメンバ変数追加

ちなみに、Settingsクラスはメンバ変数をChroMapper用の設定値として保持しているクラスで、設定ファイル(ChroMapperSettings.json)に保存して読み込む機能が備わっています。

最後にSelectSnapの呼び出し引数をtrueから0に変更します。(Firstは0なので)

変更後のStartメソッド

素直な改造(UpdateTextメソッド)

次にUpdateTextメソッドですが、ここは簡単です。今までif文でfirstActiveのbool型で2分岐していたのを、switch文で3分岐しただけです。
activeStep変数が2のときの処理として、先程追加したCursorPrecisionCとthirdDisplay.textへの代入を追加します。

変更後のUpdateTextメソッド

素直な改造(SelectSnapメソッド)

SelectSnapメソッドですが、ここは変更点が多いです。
まず、メソッドの引数firstはbool型から、int型のstepに型と名前を変更します。また、intに変更になったので、2より大きい値が設定されたら0に戻す処理を追加します。(次のSwapSelectedIntervalで生きてきます)
firstActiveはactiveStepに変数の名前を変更します。
first/SecondOutline.effectColorの設定は、firstの状態を三項演算子で判断して設定していますので、ここはstep == 0~2 の条件に変更します。
UpdateManualPrecisionStepの呼び出しの引数については、step変数の内容によって3分岐するように処理を変更します。

変更前のSelectSnapメソッド
変更後のSelectSnapメソッド

素直な変更(SwapSelectedIntervalメソッド)

最後にSwapSelectedIntervalですが、ここはSelectSnapの呼び出しの引数を、
firstActiveの否定(反転)値から、activeStep + 1 に変更します。
ここはxキー押した時の処理で、今まではFirstとSecondを交互切替だったのですが、Thirdが増えたので1つづつ増加させることにします。
Thirdが選択状態の場合は引数は3になりますが、先程のSelectSnapで2より大きい値が設定された場合は、0に戻すのでThirdの次はFirstになります。

SwapSelectedIntervalメソッドの変更

ここまででスクリプトの修正は完了です。一度保存してください。
また、エディタ上でエラーが出てないか確認してください。

素直な変更(On Selectの修正)

スクリプトの修正は終わりですが、1点忘れ物があります。
SelectSnapメソッドは各UIのInspectorのOn Selectからも呼ばれています。
引数の型がint型に変わったので、ここも変更が必要です。
First/Second/Third Intervalの各オブジェクトにあるOn SelectのSelectSnapの呼び出しを設定し直し、Firstは0, Secondは1, Thirdは2と設定して下さい。

On SelectイベントのSelectSnapの登録し直し

これで修正は完了ですので、UnityエディタのメニューのFile→Saveでシーンを保存して、ビルドし直してからChroMapperを起動して確認してみてください。修正に問題なければ、無事目的のカーソル移動単位が1つ追加されていると思います。
修正後のPrecisionStepDisplayController.csのコードを下記に貼り付けておきます。コピペして試す場合は、Settings.csのCursorPrecisionC追加も忘れずにして下さい。

using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class PrecisionStepDisplayController : DisableActionsField
{
    [SerializeField] private AudioTimeSyncController atsc;
    [SerializeField] private TMP_InputField display;
    [SerializeField] private TMP_InputField secondDisplay;
    [SerializeField] private TMP_InputField thirdDisplay;
    [SerializeField] private Outline firstOutline;
    [SerializeField] private Outline secondOutline;
    [SerializeField] private Outline thirdOutline;
    [SerializeField] private Color defaultOutlineColor;
    [SerializeField] private Color selectedOutlineColor;

    private int activeStep = 0;

    private void Start()
    {
        display.text = Settings.Instance.CursorPrecisionA.ToString();
        secondDisplay.text = Settings.Instance.CursorPrecisionB.ToString();
        thirdDisplay.text = Settings.Instance.CursorPrecisionC.ToString();

        atsc.GridMeasureSnappingChanged += UpdateText;
        SelectSnap(0);
    }

    private void OnDestroy() => atsc.GridMeasureSnappingChanged -= UpdateText;

    private void UpdateText(int newSnapping)
    {
        switch (activeStep)
        {
            case 0:
                Settings.Instance.CursorPrecisionA = newSnapping;
                display.text = newSnapping.ToString();
                break;
            case 1:
                Settings.Instance.CursorPrecisionB = newSnapping;
                secondDisplay.text = newSnapping.ToString();
                break;
            case 2:
                Settings.Instance.CursorPrecisionC = newSnapping;
                thirdDisplay.text = newSnapping.ToString();
                break;
        }
    }

    public void SelectSnap(int step)
    {
        if (step > 2)
        {
            step = 0;
        }
        activeStep = step;
        firstOutline.effectColor = step == 0 ? selectedOutlineColor : defaultOutlineColor;
        secondOutline.effectColor = step == 1 ? selectedOutlineColor : defaultOutlineColor;
        thirdOutline.effectColor = step == 2 ? selectedOutlineColor : defaultOutlineColor;
        switch (step)
        {
            case 0:
                UpdateManualPrecisionStep(display.text);
                break;
            case 1:
                UpdateManualPrecisionStep(secondDisplay.text);
                break;
            case 2:
                UpdateManualPrecisionStep(thirdDisplay.text);
                break;
        }
    }

    public void SwapSelectedInterval() => SelectSnap(activeStep + 1);

    public void UpdateManualPrecisionStep(string result)
    {
        if (int.TryParse(result, out var newGridMeasureSnapping))
        {
            if (newGridMeasureSnapping < 0)
            {
                Debug.LogError(":hyperPepega: :mega: WHY ARE YOU USING NEGATIVE PRECISION");
                newGridMeasureSnapping = Mathf.Abs(newGridMeasureSnapping);
            }
            if (newGridMeasureSnapping == 0)
            {
                Debug.LogError(":hyperPepega: :mega: WHY ARE YOU USING 1/0 PRECISION");
                newGridMeasureSnapping = 1;
            }
            atsc.GridMeasureSnapping = newGridMeasureSnapping;
        }
    }
}

プラグイン化の検討

さて、これで希望した機能が搭載されて、めでたしめでたし・・・じゃないですね、ChroMapperを直接改造する場合はこれで良いのですが、本記事の目的はプラグインの作成です。
正直記事としては、道半ばです(右のスクロールバーを見てください😨)
既に記事も1万文字を超えているので、ここまで読んでる人は興味がある人か、もの好きぐらいな気がしますが、がんばって書いて行きますよ。

まず、プラグインの利点は何でしょうか?
それは、ChroMapper本体を触らずに機能を後付できることにあります。
ChroMapperは頻繁に更新されているので、本体を直接改造すると公式の修正を改造版に取り込む必要があります。
また、プラグインなら複数の好きな組み合わせで機能追加できます。本体を改造すると、改造版Aと改造版B・・・となり、AとBの両方の機能を使うことができません。
自分一人だけで使うなら、改造版ChroMapperを使うこともありえなくは無いですが、他の人に配布することを考えると、よほど大幅な変更をして派生版ChroMapper2として今後のサポートもして行く覚悟がないとできません。
というわけで、このようなちょっとした改造はプラグインで対応したほうが、色々都合がよいのです。

プラグインの制約

さて、プラグインを作るには先程行った改造をプラグインによって外部から行う必要があります。その場合、色々と制約があります。

  • ChroMapper本体クラスの既存のメンバ変数の型や名前は変更できない。

  • 本体クラスの既存メソッドは、呼び出し前(Prefix)と後(Postfix)にパッチを当てて処理の追加が可能。Prefixでは処理後に既存メソッドを呼ぶか選択できる。Postfixでは既存メソッドの戻り値が取得できる。

  • Unityエディタ上で操作設定した内容は、全てスクリプトで記述して呼び出す必要がある。

  • Inspectorで設定したOn Selectなどのイベントは削除ができない。

まず、1番目のメンバ変数の変更の制約ですが、bool型のfirstActiveをint型のactiveStepに変更ができません。なので、firstActiveは触らずにactiveStepをプラグイン側で別に持っておく必要があります。

次に、2番目のメソッドの処理変更ですが、既存メソッド内部の既存の処理は変更できません。既存メソッドの処理も含めて丸々プラグインで用意した処理に差し替えれば可能ですが、それは避けたいです。今後ChroMapperが更新したときに既存メソッドの中身が変更になった場合に、プラグインを入れると古い処理に戻ってしまって、いらぬバグを呼ぶ可能性があります。プラグインはプラグインで追加した内容の処理だけに留めるべきです。

3番目ですがプラグインは全てスクリプトで操作することになるので、Unityエディタ上で操作した、オブジェクトのコピーとか、Inspectorでの設定みたいな操作は全て対応したUnityのメソッドなどを調べて記述が必要です。

4番目のOn Selectのイベント削除ですが、Unityのイベントはスクリプトで呼び出し対象のメソッドの追加削除は可能ですが、Inspectorで設定したイベントはPersistentEventとして永続的に設定されるので、スクリプトからは削除や呼び出し内容の変更ができません。ただし、SetPersistentListenerStateを使って、呼び出しのOff変更はできます。

Inspectorで設定したイベントは永続的に残る

要するに、既存のコードの書き換えは避けて、処理の追加だけで済むようにする必要があります。

これらの制約を考えると先ほど少し触れましたが、firstActive変数の対応が面倒です。先ほどの改造ではUpdateTextやSelectSnapでactiveStepの状態を見て3分岐していましたが、この方法が取れません。

方法は色々ありますが、firstActiveでのFirst/Secondの判断は残しつつ、プラグイン側にactiveStep変数を保持して、メソッドの呼び出し前に選択状態が2(Third)のときに追加の処理を実行する。
Third IntervalのOn Selectの呼び出しは、既存のSelectSnapは停止して、別に新規作成したメソッドを登録する。と言った方法で実現できそうです。

制約に対応したスクリプト

いきなりプラグインを作っても良いのですが、まずは上記制約を加味した状態でChroMapperの改造をしてみます。

改造前のオリジナルのChroMapperのプロジェクトを開き直して、Second IntervalのオブジェクトをCopy & PasteしてThird Intervalを追加するのは同じです。次にPrecisionStepDisplayControllerのスクリプトを開きます。

まずはメンバ変数の追加です。
thirdDisplayとthirdOutlineの変数追加は先程と同じです。
InspectorでThird Intervalのオブジェクトと、その子のBackgroundを設定追加も同じなので忘れずに行ってください。
firstActive変数はそのまま残して、int型のcurrentStep変数を追加します。
(先程はactiveStepと名付けましたが、現在の選択状態を保持する意味合いで変更しています。[単に先程の記事の修正がめんどうなだけです😅])

メンバ変数の追加内容

次にStartメソッドですが、thirdDisplay.txtへの初期値設定は先程と同じです。SettingsにCursorPrecisionCのメンバ変数追加も忘れずに行ってください。先ほどはSelectSnapの引数を0に変更しましたが、変更できないのでこのままです。

Startメソッドの追加内容

メソッドの中間で良いのか?と疑問があるかもしれませんが、StartはUnityから最初に呼び出されるメソッドです。プラグインで追加したクラスでも同様にStartがあるので、PrecisionStepDisplayControllerのStartに追加と言う意味では無いので、位置は問いません。

次にUpdateTextメソッドです。
メソッドの最初(Prefix)にcurrentStepが2のとき、つまりThird Intervalが選択中のときの処理を追加します。returnでメソッドを抜けているのは、プラグインにしたときに、Thirdが選択中のときは、既存メソッドを呼び出さない状態を模擬しています。

UpdateTextメソッドの追加内容

次にSelectSnapメソッドです。
こちらもメソッドの最初(Prefix)に処理を追加しています。
SelectSnapは既存のFirst/Secondのときだけ呼び出されます。
そのため、thirdOutline.effectColorは常にdefaultOutlineColorです。
First/Secondの状態をここでcurrentStepに設定します。

SelectSnapメソッドの追加内容

次にThirdStepメソッドを追加します。
これは、Third Intervalが選択されたときに、SelectSnapの代わりに呼び出すメソッドです。ここにThird Interval用のSelectSnapの処理を追加します。
まず、firstActiveはfalseに設定します。これは、次のSwapSelectedIntervalでSelectSnap(!firstActive)で呼び出しているため、xキーで切り替えるときにThirdの次はFirstにする必要があるためです。

ThirdStepメソッドを新規追加

ThirdStepを追加したら、スクリプトを保存してThird IntervalのInspectorのOn Selectのイベントで、SelectSnapの呼び出しをOffにして、ThirdStepの呼び出しを追加します。

Third IntervalのOn Select設定の変更と追加

最後にSwapSelectedIntervalメソッドですが、元々はラムダ式(=>)で直接SelectSnap(!firstActive)を呼び出していたので、通常のメソッド形式にして最初(Prefix)に処理を追加します。最後のSelectSnap(!firstActive)は変更していません。currentStepをインクリメントして、2(Third Interval)より大きければ0に戻して、Third IntervalのときだけThirdStepを呼び出して、returnで既存メソッドは呼ばないようにします。

SwapSelectedIntervalメソッドの追加内容

変更完了したらスクリプトとシーンを保存してビルドして確認します。
先ほどと挙動が同じなのを確認できたでしょうか?
このように、処理の追加だけで行うことがプラグインを作る上で必要になります。

修正後のPrecisionStepDisplayController.csのコードを貼り付けておきます。

using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class PrecisionStepDisplayController : DisableActionsField
{
    [SerializeField] private AudioTimeSyncController atsc;
    [SerializeField] private TMP_InputField display;
    [SerializeField] private TMP_InputField secondDisplay;
    [SerializeField] private TMP_InputField thirdDisplay;
    [SerializeField] private Outline firstOutline;
    [SerializeField] private Outline secondOutline;
    [SerializeField] private Outline thirdOutline;
    [SerializeField] private Color defaultOutlineColor;
    [SerializeField] private Color selectedOutlineColor;

    private bool firstActive;
    private int currentStep = 0;

    private void Start()
    {
        display.text = Settings.Instance.CursorPrecisionA.ToString();
        secondDisplay.text = Settings.Instance.CursorPrecisionB.ToString();
        thirdDisplay.text = Settings.Instance.CursorPrecisionC.ToString();

        atsc.GridMeasureSnappingChanged += UpdateText;
        SelectSnap(true);
    }

    private void OnDestroy() => atsc.GridMeasureSnappingChanged -= UpdateText;

    private void UpdateText(int newSnapping)
    {
        if (currentStep == 2)
        {
            Settings.Instance.CursorPrecisionC = newSnapping;
            thirdDisplay.text = newSnapping.ToString();
            return;
        }
        if (firstActive)
        {
            Settings.Instance.CursorPrecisionA = newSnapping;
            display.text = newSnapping.ToString();
        }
        else
        {
            Settings.Instance.CursorPrecisionB = newSnapping;
            secondDisplay.text = newSnapping.ToString();
        }
    }

    public void SelectSnap(bool first)
    {
        thirdOutline.effectColor = defaultOutlineColor;
        if (first)
            currentStep = 0;
        else
            currentStep = 1;
        firstActive = first;
        firstOutline.effectColor = first ? selectedOutlineColor : defaultOutlineColor;
        secondOutline.effectColor = !first ? selectedOutlineColor : defaultOutlineColor;
        UpdateManualPrecisionStep(first ? display.text : secondDisplay.text);
    }

    public void ThirdStep()
    {
        firstActive = false;
        currentStep = 2;
        firstOutline.effectColor = defaultOutlineColor;
        secondOutline.effectColor = defaultOutlineColor;
        thirdOutline.effectColor = selectedOutlineColor;
        UpdateManualPrecisionStep(thirdDisplay.text);
    }

    public void SwapSelectedInterval()
    {
        ++currentStep;
        if (currentStep > 2)
            currentStep = 0;
        if (currentStep == 2)
        {
            ThirdStep();
            return;
        }
        SelectSnap(!firstActive);
    }

    public void UpdateManualPrecisionStep(string result)
    {
        if (int.TryParse(result, out var newGridMeasureSnapping))
        {
            if (newGridMeasureSnapping < 0)
            {
                Debug.LogError(":hyperPepega: :mega: WHY ARE YOU USING NEGATIVE PRECISION");
                newGridMeasureSnapping = Mathf.Abs(newGridMeasureSnapping);
            }
            if (newGridMeasureSnapping == 0)
            {
                Debug.LogError(":hyperPepega: :mega: WHY ARE YOU USING 1/0 PRECISION");
                newGridMeasureSnapping = 1;
            }
            atsc.GridMeasureSnapping = newGridMeasureSnapping;
        }
    }
}

プラグイン作成の準備

さてChroMapperプラグインの作成方法ですが、公式の説明はここです。
Plugin Guide
英語なので、日本語訳を私のGithubのWikiに用意してあります。
ChroMapper プラグイン作成ガイドの日本語訳
ただ、これを読んで分かる人は、この記事を読まなくても分かるレベルな気がします。

これだと、ちょっと難しいので私のほうでテンプレートを作成して配布しています。
ChroMapperPluginTemplates
インストール方法はREADMEに記載どおりで、Releasesからzipファイル3つをダウンロードして
(ドキュメントフォルダ)\Visual Studio 2022\Templates\ProjectTemplates
にzipファイルのまま配置します。(解凍しない)

プラグイン用のテンプレートを配置

この状態で、VisualStudio2022をスタートメニューから起動します
右側の開始するから「新しいプロジェクトの作成」を選択します。

新しいプロジェクトの作成

次にchroを上の入力欄に入れて、テンプレートをフィルタリングして
ChroMapper-Plugin(OptionHarmony)をダブルクリックします。

テンプレートの検索と選択

次にプロジェクト名の設定です。
名前は何でも良いのですが、ここを後から変更するのは結構面倒なので、最初にきちんと決めて下さい。私はBeatSaberのmodと区別するために、頭にChroMapper-と付けるようにしています。後の名前はプラグインの機能から命名します。(modでもそうですが、ここが一番悩みます)
今回はChroMapper-PrecisionStepAdditionsとします。
プロジェクトの保存場所は任意です。設定したら作成します。

プロジェクト名の命名:ここが一番の悩みどころ

Visual Studioのエディタが開いたら、右のソリューションエクスプローラーからPlugin.csを開いて見ますが、エラーがいっぱい出ています。
これは参照のうちいくつかのアセンブリの読み込みエラーのためです。

エラーいっぱい

このままだと困るので、先ほどのテンプレートのReleasesから、ProjectName.csproj.userをダウンロードして、先ほど作成したChroMapper-PrecisionStepAdditionsプロジェクトの.csprojがあるフォルダにコピーして、
ファイル名のProjectNameの部分を.csprojファイルの前の部分と同じにします。

.userの.csprojファイルを作成

これは何かと言うと、.csprojファイルはC#のプロジェクト設定が色々書かれたファイルですが、同じ名称の末尾が.userのファイルがあると、ユーザ個別の設定を追記できるのです。
これによって、個人の環境に依存する設定(ChroMapperのインストールフォルダなど)を分離して、GitHubなどでソースコードを共有するときに.userは除くことで、個人環境に依存する部分を分離することができます。

リネームしたら、メモ帳などで開いて<ChroMapperDir>にChroMapperのインストール場所を設定します。設定して保存したら、VisualStudioを一度閉じて、プロジェクトフォルダのChroMapper-PrecisionStepAdditions.slnをダブルクリックして開き直します。

ChroMapperのインストール場所設定(クリックして拡大表示)

開き直してもエラーの場合は、ソリューションエクスプローラーの参照の中を適当にクリックするとエラーが消えると思います。
エラーが消えない場合は、.userの設定を再度確認してください。
ChroMapper.exeがあるフォルダを設定する必要があります。

さて、この状態でまずはビルドが通るか確認します。
メニューのビルドからソリューションのビルドを選択してください。
下の出力画面のビルドが成功 1になってるか、ChroMapperのプラグインフォルダにChroMapper-PrecisionStepAdditions.dllが生成されているか確認します。

ChroMapperを起動してみてプラグインが読み込まれていれば、ビルドまで確認完了です。

ChroMapperでプラグインが読み込まれている

プラグインのコード作成

このままだと、何もしないプラグインなので中身を作成していきます。

テンプレートの説明

まずは、テンプレートで自動生成されたコードを解説します。
まずはPlugin.csの説明をします。

Plugin.csのコード

ChroMapperのプラグインは、[Plugin]属性が付いているクラスが起点になります。パラメータとして文字列でプラグイン名を付けます。プラグイン名はChroMapperのSettings画面で表示されます(先ほどの画像)
また、[Init]属性があるメソッドがChroMapper起動時(プラグイン読込時)に実行され、[Exit]属性があるメソッドがChroMapper終了時に実行されます。

次にHarmonyと言う名前の付いたクラスが目に付くと思います。
これは先程の「制約に対応した改造」で、「ChroMapper本体クラスの既存メソッドの呼び出し前後に処理を追加」する部分で使います。
起動時にPatchAllで、このプラグイン内のHarmonyパッチを既存メソッドに当てて、終了時にUnpatchSelfでパッチを外しています。

Harmonyを使うと、C#のプログラム動作中に既存のメソッドの動作を変更できます。詳しい説明はHarmonyの公式サイトを見てください。
ちなみに最新のChroMapperやBeatSaberのmodではHarmonyのフォークであるHarmonyXが使われていて多少違いますが、基本的にはHarmonyと一緒です。

次にConfigurationフォルダにOptions.csがあります。

Options.cs(クリックして拡大表示)

これはChroMapper本体であったSettings.csと同じ役割で、プラグイン用の設定値を保持するクラスです。このクラスのメンバ変数がオプション値として保持できます。ただし簡易版なので数値(int, floatなど) や真偽値(bool)、文字列(string)の型のみで、配列(Array)などは使えません。
使用する場合は、メンバ変数を追加してOptions.Instance.変数名 でアクセスするだけです。ただし、設定ファイルの保存は自動ではないので、必要なとき(終了時など)にOptions.Instance.SettingSave();を呼ぶ必要があります。

テンプレートはCore, Option, OptionHarmonyと3つありましたが、CoreはPlugin.csのみ、Optionは追加でOptions.cs、OptionHarmonyはさらに追加でHarmony用のアセンブリ参照などが追加される違いがあります。

※VisualStuido2022のVer17.12.1以降だと、使用されていないprivateメソッドが薄い表示に抑制されてしまいます。17.12.3でUnityプロジェクトではUnityメッセージのメソッドについては修正されました。しかし、Unityプロジェクトではないプラグインや、[Init]属性のような形で呼ばれるprivateメソッドの表示は薄いままです。薄い表示のままでも動作に支障はありませんが、.editorconfigで薄い表示にする機能の無効化も可能です。もしくは、publicにしてしまう方法もあります。

呼び出しの無いprivateメソッドは使われないと判断されて薄くなる
ヒントから抑制表示機能の構成変更
適用するとこのプロジェクトでは薄くならなくなる

プラグイン名の修正

さきほど、BeatSaberのプラグインと混同を避けるためにプロジェクト名にChroMapper-を追加しましたが、ChroMapperの中で表示するには冗長なので、修正しましょう。
Plugin.csのプラグイン名を Precision Step Additions と修正します。

Plugin.cs の Plugin属性パラメータ(プラグイン名)を修正

あと、Options.csの設定ファイル名も修正しておきます。

Options.csの設定ファイル名

Plugin.csでHARMONY_IDにもChroMapper-がありますが、ここはそのままにしておきます。その変わり、GitHubで公開予定でしたらusernameの部分をGitHubのアカウント名に変更します。
HARMONY_IDは何かと言うと、プラグイン同士でHarmonyを使う時にお互いを識別するためのIDで、そのプラグインの公開場所を逆順に書いていくのが一般的です。識別のためなので、他のプラグインと衝突しなければ何でもよいです。

HARMONY_ID

シーン切替の判断

では、早速プラグイン本体を作成していきます。
まずは起点となるPlugin.csです。

まず、このプラグインは03_Mapperシーンで動作します。
Unityエディタを使った改造では、該当シーンのHierarchy上のInspectorを触れば良いですが、プラグインではまずシーンの状態を監視して、03_Mapperシーンになったら動作するようにする必要があります。

切替はUnityEngine.SceneManagement.SceneManager.sceneLoadedで検出できます。このままだと長いので、省略するためコードの先頭にusing UnityEngine.SceneManagement; と追加すると、SceneManager.sceneLoadedと記述できるようになります。

シーン切替処理用のSceneLoadedメソッドを追加して、Initメソッド内で+=でイベント登録するとシーン切替時に呼び出されます。
SceneLoadedメソッドでは現在のシーンを判断して処理を進めます。
ちなみに、+=でイベント登録したら不要になるタイミングで-=で解除もしておきます。
この場合、プラグインの終了はChroMapperの終了なので、もうシーン切替は発生しないので、解除しなくても問題ないのですが、03_Mapperシーン内だけで存在するようなメソッドを登録する場合は解除を忘れると、シーン切替時に参照先がなくなってエラーになります。
PrecisionStepDisplayControllerでもStart()でUpdateTextをGridMeasureSnappingChangedにイベント登録していましたが、OnDestroy()で解除していますよね。

SceneManager.sceneLoaded によるシーン検出

SceneLoadedメソッドでは、引数のarg0に現在のシーンが入るので、buildIndexから判断します。
03_Mapperは3なので、3以外ではreturnで除外します。buildIndexはChroMapperビルド画面の右端の数字です。覚えていますか?

プラグイン用クラスの作成・登録

次にプラグイン用のクラスを用意します。ソリューションエクスプローラのChroMapper-PrecisionStepAdditionsを右クリックして追加のクラスからStepAdditionControllerを追加します。

StepAdditionControllerクラスの追加

StepAdditionController.csが作成されたら、クラスのアクセス修飾子をpublicにして、UnityEngine.MonoBehaviourクラスを継承しておきます。

作成されたStepAdditionController.csの修正

publicにするのは、他のプラグインからもアクセスできるようにしておくためです。internalのままでも構いませんが、その場合はアクセス権限が一貫するように呼び出し側(Plugin.cs)でも修正が必要です。アクセス修飾子をどうするかは、プラグイン作者の方針次第です。

MonoBehaviourと言うのはUnityのゲームオブジェクトにコンポーネントとして登録するクラスでは継承が必須となります。コンポーネントとして登録すると、フレーム毎にUnityからStartとかUpdateを呼んでもらって処理ができるようになります。

次に、Plugin.csに戻って、作成したStepAdditionControllerのインスタンスを保持するためにstatic変数でstepAdditionControllerをPluginのメンバ変数として登録します。

あとは、SceneLoadedメソッドで、PrecisionStepDisplayControllerが登録されているゲームオブジェクト(Cursor Interval)を探して、StepAdditionControllerをコンポーネントとして登録します。

StepAdditionControllerをCursor Intervalのコンポーネント登録

stepAdditionControllerのnull判定と、isActiveAndEnabledのチェックは、二重にStepAdditionControllerが登録されないようにするための予防措置です。

Resources.FindObjectsOfTypeAllはPrecisionStepDisplayControllerが登録されているゲームオブジェクト(Cursor Interval)を探して返します。配列で返されるのでFirstOrDefaultで最初に見つかったものを取得します。

Cursor Intervalオブジェクトが見つかったら、AddComponentでStepAdditionControllerクラスを登録します。この操作はUnityエディタ上のAdd Componentボタンで登録することと同じです。
AddComponentは登録したコンポーネントのインスタンスを返すので、他のクラスからのアクセス用にメンバ変数で保持しておきます。

プラグインのコンポーネントクラス(今回はStepAdditionController)を、どのゲームオブジェクトに登録するかですが、方法は色々あります。通常はプラグイン内で新規にゲームオブジェクトを作って、そこに割り当てることが多いです。今回はPrecisionStepDisplayControllerに対する改造なので、このような形で登録しています。

Plugin.csはこれで終わりです。
作成したコードは以下です。

using HarmonyLib;
using System.Linq;
using System.Reflection;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace ChroMapper_PrecisionStepAdditions
{
    [Plugin("Precision Step Additions")]
    public class Plugin
    {
        public static Harmony _harmony;
        public const string HARMONY_ID = "com.github.rynan4818.ChroMapper-PrecisionStepAdditions"; //username を各自変更してください
        public static StepAdditionController stepAdditionController;
        [Init]
        private void Init()
        {
            _harmony = new Harmony(HARMONY_ID);
            _harmony.PatchAll(Assembly.GetExecutingAssembly());
            SceneManager.sceneLoaded += SceneLoaded;
            Debug.Log("ChroMapper-PrecisionStepAdditions Plugin has loaded!");
        }

        private void SceneLoaded(Scene arg0, LoadSceneMode arg1)
        {
            if (arg0.buildIndex != 3) // Mapper scene
                return;
            if (stepAdditionController != null && stepAdditionController.isActiveAndEnabled)
                return;
            var cursorInterval = Resources.FindObjectsOfTypeAll<PrecisionStepDisplayController>().FirstOrDefault();
            if (cursorInterval == null)
                return;
            stepAdditionController = cursorInterval.gameObject.AddComponent<StepAdditionController>();
        }

        [Exit]
        private void Exit()
        {
            SceneManager.sceneLoaded -= SceneLoaded;
            _harmony.UnpatchSelf();
            Debug.Log("ChroMapper-PrecisionStepAdditions Plugin has closed!");
        }
    }
}

StepAdditionController用メンバ変数の準備

さて、これでChroMapperでエディタ画面を起動すれば、作成したStepAdditionControllerが動く環境が整いました。
あとは、この中に「制約に対応したスクリプト」に相当するコードを書いていけば完成です。

まずはメンバ変数の準備をします。
先ほどのChroMapperの改造では既存のクラスを改造していましたので、不足するものを追加するだけで良かったのですが、プラグインでは全部準備する必要があります。

プラグイン用メンバ変数

precisionStepDisplayControllerは、SelectSnapやUpdateManualPrecisionStepを呼ぶのに必要です。
firstActiveの型が見慣れないTraverse (HarmonyLib.Traverse)となっていますが、後で説明します。
OutlineTMP_InputFieldの型がエラーになっていますが、これは参照アセンブリが不足しているためです。後ほど追加します。
initはプラグインの初期化が完了したかどうかのフラグとして使います。

メンバ変数がプロパティ { set; get; } の形になっているのは、他のクラスから参照する変数です。プロパティにすると参照先がわかるので便利です。
ここは最初からこの形ではなく、コードを書きながら、外部参照が必要なのでprivateからpublicにしてプロパティ化しています。

参照アセンブリの追加

まずは、エラーが出ている参照アセンブリの追加をします。
OutlineのエラーはChroMapperのインストールフォルダにある ChroMapper_Data\Managed\UnityEngine.UI.dll
を参照することで解消します。
TMP_InputFieldのエラーは同じフォルダの Unity.TextMeshPro.dll です。
これらがどのDLLにあるか調べる方法ですが、ChroMaperのソースコードを開いてTMP_InputFieldの型のヒントから該当するクラスを開いて、そのファイル名のところにビルド対象のDLLが表示されるので、そこで確認すると分かります。

ChroMapperのソースから対象のアセンブリを調査

参照アセンブリの追加はソリューションエクスプローラーから追加すれば良いのですが、絶対パスで登録されるのでソースコードを公開するときは都合が悪いです。
そのため、テキストエディタ(メモ帳)で.csprojファイルを開いて、<HintPath>のChroMapperのパスの部分を$(ChroMapperDir)に手動で書き換える必要があります。
よく使用するライブラリは、私が下記でまとめていますので
よく使用するライブラリ
<ItemGroup>にコピー&ペーストしても構いません。

.csprojファイルに参照アセンブリを追加
参照アセンブリを追加してエラー解消

メンバ変数の初期化

さて、まずはStartメソッドを作って、先程作成したメンバ変数の中身を初期化していきます。

メンバ変数の初期化

まず、precisionStepDisplayControllerは、自分自身(StepAdditionController)が登録されているゲームオブジェクト(Cursor Interval)にいますので、自分自身のインスタンス(this)のgameObjectで取ってこれます。あとは、GetComponentで取得可能です。

ここまでは問題ないのですが、ChroMapperのPrecisionStepDisplayControllerのソースコードを見ると、クラスはpublicアクセス可能ですが、メンバ変数は全てprivateでした。このままだと参照することができません。
プラグインの作成とかBeatSaberのmodを作ると、必然的にこの問題によく直面します。通常このような場合はリフレクションと言う機能を使ってアクセス権を無視して無理やり参照します。

このリフレクションは扱うのがちょっと面倒なのですが、HarmonyLibにはTraverseという便利な機能があり、リフレクションによるアクセスを簡単にしてくれます。また、リフレクションは処理が遅い問題がありますが、Traverseはキャッシュして高速化してくれたり、参照途中で発生したnullを伝えてくれるなど色々便利になっています。

まずは、Traverse.Createで参照したいインスタンス(precisionStepDisplayController)を引数に渡して、参照用のTraverseを取得します。あとはFieldメソッドで取得したいメンバ変数の名前を文字列で与えて、GetValueで対象の型を指定すると取得できます。
firstActiveについては後から値を変更したいので、Traverseの状態で保持します。

Third Intervalの作成

さて、まだ初期化でやることがあります。Third Intervalオブジェクトの作成です。Unityエディタではマウスで選択してメニューのCopy&Pasteで、できましたが、もちろんコードで書く必要があります。

Third Intervalオブジェクトの作成

まずは、this.transform.Findで自分自身の登録先のゲームオブジェクト(Cursor Interval)の子どもにいる"Second Interval"オブジェクトを探して取得します。
次に、Instantiateで自分自身を親としてCopy&Pasteします。
そしてコピーしたオブジェクトを"Third Interval"に名前変更します。
あとは、コピーしたThird Intervalから、Third Displayに当たるTMP_InputFieldコンポーネントと、子どもの"Background"オブジェクトにいるOutlineコンポーネントをGetComponentして、メンバ変数に割り当てます。

for文の中は、Inspectorで永続登録されたOn SelectイベントのSelectSnapメソッド呼び出しをOff設定に変更しています。
最後は、Offしたイベントの変わりにAddListenerで、プラグイン内のThirdStepメソッドを登録しています。

あとは、本来のStartで処理する予定だった、Third Intervalへの初期値の追加も忘れずにします。ChroMapperはSettingsに設定を追加しましたが、プラグイン用のOptionsに追加します。

Startの残りの処理

メンバ変数のinitをtrueにするのは、初期化処理が完了したフラグ立てです。
最後にSelectSnapを呼んでいる理由は後から説明します。
これで、Startメソッドの処理は終了です。

ThirdStepメソッド

ThirdStepメソッドは「制約に対応したスクリプト」とほぼ一緒です。

ThirdStepメソッド

上がChroMapperの改造で、下がプラグインです。
firstActiveはTraverseなので、SetValueで実際の変数の値を変更します。

firstOutlineやsecondOutlineもStartメソッドでTraverseを使って参照しましたが、SetValueしなくても良いのかと思う方もいるかもしれません。
firstActiveは変数の中身のFalse/Trueを直接変更が必要なのでSetValueが必要です。対して、firstOutlineの中身はOutlineクラスのインスタンスで、インスタンス自体は変更がなく、effectColorプロパティの値を変更をしているだけなのでSetValueは不要です。

エディタ終了時の処理

OnDestroyメソッドは登録先のゲームオブジェクトが破棄されるときに、Unityによって呼ばれるメソッドです。つまり、03_Mapperシーンを終了してエディタを閉じるときに呼ばれるメソッドです。

エディタ終了時の処理

まず、UnityEventはRemoveAllListenersをしないと、対象のゲームオブジェクトが破棄されても残り続けるそうなので、AddListenerをしたThirdStepの登録を削除しています。

次にThird Intervalの設定値を保存します。SettingSaveをどのタイミングで呼ぶかですが、今回はそんなに重要な設定値ではなく、仮にエディタ画面で落ちた場合に保存されないぐらいの問題にしかならないので、今回はOnDestroyで保存することにしました。

これで、StepAdditionController.csのコードは終了です。
完成したコードは下記になります。

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
using TMPro;
using HarmonyLib;
using ChroMapper_PrecisionStepAdditions.Configuration;

namespace ChroMapper_PrecisionStepAdditions
{
    public class StepAdditionController : MonoBehaviour
    {
        private PrecisionStepDisplayController precisionStepDisplayController;
        private Traverse firstActive;
        private Outline firstOutline;
        private Outline secondOutline;
        private Color selectedOutlineColor;
        public Color defaultOutlineColor { get; set; }
        public TMP_InputField thirdDisplay { get; set; }
        public Outline thirdOutline { get; set; }
        public int currentStep { set; get; } = 0;
        public bool init { get; set; } = false;
        private void Start()
        {
            this.precisionStepDisplayController = this.gameObject.GetComponent<PrecisionStepDisplayController>();
            var psdcTraverse = Traverse.Create(this.precisionStepDisplayController);
            this.firstActive = psdcTraverse.Field("firstActive");
            this.firstOutline = psdcTraverse.Field("firstOutline").GetValue<Outline>();
            this.secondOutline = psdcTraverse.Field("secondOutline").GetValue<Outline>();
            this.selectedOutlineColor = psdcTraverse.Field("selectedOutlineColor").GetValue<Color>();
            this.defaultOutlineColor = psdcTraverse.Field("defaultOutlineColor").GetValue<Color>();

            // Third Intervalの作成
            var secondInterval = this.transform.Find("Second Interval");
            var thirdInterval = Instantiate(secondInterval, this.transform);
            thirdInterval.name = "Third Interval";
            this.thirdDisplay = thirdInterval.GetComponent<TMP_InputField>();
            this.thirdOutline = thirdInterval.Find("Background").GetComponent<Outline>();
            for (int i = 0; i < this.thirdDisplay.onSelect.GetPersistentEventCount(); i++)
            {
                //https://docs.unity3d.com/ja/2018.4/ScriptReference/UI.InputField.SubmitEvent.html
                if (this.thirdDisplay.onSelect.GetPersistentMethodName(i) == "SelectSnap")
                    this.thirdDisplay.onSelect.SetPersistentListenerState(i, UnityEventCallState.Off);
            }
            this.thirdDisplay.onSelect.AddListener((s) => this.ThirdStep());

            this.thirdDisplay.text = Options.Instance.cursorPrecisionC.ToString();
            this.init = true;
            this.precisionStepDisplayController.SelectSnap(true);
        }
        public void ThirdStep()
        {
            this.firstActive.SetValue(false);
            this.currentStep = 2;
            this.firstOutline.effectColor = this.defaultOutlineColor;
            this.secondOutline.effectColor = this.defaultOutlineColor;
            this.thirdOutline.effectColor = this.selectedOutlineColor;
            this.precisionStepDisplayController.UpdateManualPrecisionStep(this.thirdDisplay.text);
        }
        private void OnDestroy()
        {
            this.thirdDisplay.onSelect.RemoveAllListeners();
            Options.Instance.SettingSave();
        }
    }
}

Harmonyパッチの作成

さて、これで終わりではありません。あと、もう一押しあります。
そうです、既存メソッドの変更です。
PrecisionStepDisplayControllerにある、UpdateText・SelectSnap・SwapSelectedIntervalメソッドにHarmonyでパッチを当てて処理を追加する必要があります。

まず、パッチを入れるフォルダを作ります。
ソリューションエクスプローラでHarmonyPatchesフォルダを追加します。

HarmonyPatchesフォルダの追加(お好み)

この作業はお好みですが、このような形が一般的になっているので、それに習います。別にフォルダ管理しないと動かないとか無いです。

フォルダを作ったらその中にパッチを当てる用のクラスを作ります。
Harmonyでパッチを当てる場合はいくつかルールがあります。
まず、当てたい対象のメソッド1つにパッチ用のクラス1つ作る必要があります。今回は3つのメソッドに当てるので3つのクラスを作ります。
クラス名は何でも良いです。
私の場合は「パッチ対象のクラス名_メソッド名Patch」にしています。

C#では1クラス=1ファイルにすることが普通なので、3つのファイルにします。(1つにまとめても動きます、お好みです)

まずは、UpdateTextのパッチを当てます。
ChroMapperの改造で行ったコードと、プラグインでのパッチを比較してみましょう。

UpdateTextへのパッチ

パッチの方は何やらいろいろありますが、Prefixメソッドの処理をよく見るとChroMapperの改造と同じ処理をしているのが分かります。
currentStepとthirdDisplayはStepAdditionControllerにあるので、Pluginのstatic変数にしたstepAdditionControllerを通してアクセスします。

最後のreturnの戻り値としてbool型を渡していますが、これは既存のメソッドを呼ぶか呼ばないかを選択しています。

Harmonyのパッチ用のクラスは専用の属性[HarmonyPatch]を付けたクラスが対象になります。属性のパラメータの1番目はパッチ対象のクラスのTypeを渡して、2番目のパラメータに対象のメソッド名を文字列で渡します。

そして、クラス内にPrefixもしくはPostfixと言う名前のstaticメソッドを作成します。Prefixはパッチ対象のメソッドの処理前、Postfixは処理後に処理を追加します。Prefixの場合は戻り値にbool型を指定すると、既存のメソッドを実行するかどうか選べます。

PrefixやPostfixの引数には、パッチを当てるのメソッドの引数と同じ型と名前(int newSnapping)を指定すると、その引数を取得できます。今回は使用しませんが、ここには特殊な引数名でパッチ対象のインスタンス(__instance)やメンバ変数(___fields)を取得したり、Postfixで戻り値(__result)を取得したりできます。詳しくはHarmonyのドキュメントのPatchingを参照して下さい。

続いてSelectSnapのパッチです。

SelectSnapへのパッチ

まず、属性[HarmonyPatch]の2番目パラメータのメソッド名の指定がnameofになっていますが、ここは文字列で"SelectSnap"としても動きますが、参照可能な場合はnameofにしておいた方が、今後ChroMapperが更新されたときに参照エラーになってエディタ上で分かるのでnameofで取得できる場合はこうしています。UpdateTextはprivateメソッドなので断念しています。

あとは最初にstepAdditionController.initで初期化完了しているかどうか判断しています。これが無いとその次のthirdOutline.effectColorの設定でエラーになります。既存のPrecisionStepDisplayControllerとプラグインのStepAdditionControllerのStartメソッドの実行タイミングの問題です。
PrecisionStepDisplayControllerのStartが先に実行されると最後にあるSelectSnap(true)で、このパッチが実行されてしまいます。その時点ではthirdOutlineは中身が入っていないので参照エラーになります。
そのため、初期化していなかったらreturnで処理を返すようにしています。
その代わりにStepAdditionControllerで初期化後にSelectSnap(true)を呼んでいます。

最後はSwapSelectedIntervalのパッチです。ここは説明不要ですね。

SwapSelectedIntervalへのパッチ

3つのパッチが完成したら、プラグインのコードは完成です。
ビルドしてみて動作するか確認してください。

出来上がったパッチのコードを下記に貼り付けます。
PrecisionStepDisplayController_UpdateTextPatch.cs は以下です。

using HarmonyLib;
using ChroMapper_PrecisionStepAdditions.Configuration;

namespace ChroMapper_PrecisionStepAdditions.HarmonyPatches
{
    [HarmonyPatch(typeof(PrecisionStepDisplayController), "UpdateText")]
    public class PrecisionStepDisplayController_UpdateTextPatch
    {
        public static bool Prefix(int newSnapping)
        {
            if (Plugin.stepAdditionController.currentStep == 2)
            {
                Options.Instance.cursorPrecisionC = newSnapping;
                Plugin.stepAdditionController.thirdDisplay.text = newSnapping.ToString();
                return false;
            }
            return true;
        }
    }
}

PrecisionStepDisplayController_SelectSnapPatch.cs は以下です。

using HarmonyLib;

namespace ChroMapper_PrecisionStepAdditions.HarmonyPatches
{
    [HarmonyPatch(typeof(PrecisionStepDisplayController), nameof(PrecisionStepDisplayController.SelectSnap))]
    public class PrecisionStepDisplayController_SelectSnapPatch
    {
        public static bool Prefix(bool first)
        {
            if (!Plugin.stepAdditionController.init)
                return false;
            Plugin.stepAdditionController.thirdOutline.effectColor = Plugin.stepAdditionController.defaultOutlineColor;
            if (first)
                Plugin.stepAdditionController.currentStep = 0;
            else
                Plugin.stepAdditionController.currentStep = 1;
            return true;
        }
    }
}

PrecisionStepDisplayController_SwapSelectedIntervalPatch.cs は以下です。

using HarmonyLib;

namespace ChroMapper_PrecisionStepAdditions.HarmonyPatches
{
    [HarmonyPatch(typeof(PrecisionStepDisplayController), nameof(PrecisionStepDisplayController.SwapSelectedInterval))]
    public class PrecisionStepDisplayController_SwapSelectedIntervalPatch
    {
        public static bool Prefix()
        {
            ++Plugin.stepAdditionController.currentStep;
            if (Plugin.stepAdditionController.currentStep > 2)
                Plugin.stepAdditionController.currentStep = 0;
            if (Plugin.stepAdditionController.currentStep == 2)
            {
                Plugin.stepAdditionController.ThirdStep();
                return false;
            }
            return true;
        }
    }
}

なお、プラグインの最終的なソースコードは以下にも置いてあります。
ChroMapper-PrecisionStepAdditionsのAdventCalendar2024

プラグイン作成のあとがき

さて、いかがだったでしょうか?
かなり詳細に解説したので、記事がとんでもない量になりました。

今回プラグインを作ってハマったところは、「Inspectorで設定したイベントはPersistentEventとして永続的に設定される」ってところです。
最初はRemoveAllListenersしてから既存の設定も含めて再登録が必要だと思ってました。プラグインを作る立場にならないと気が付かない問題かもしれません。こういうのは結構あるあるです。

私が普段プラグインを作るときはChroMapperで一度作ることはせず、直接プラグインを書いています※が、今回は順序立てて説明するために、このような形を取りました。
ある程度分かってる人には、退屈だったかもしれません。こういう記事はレベルをどこにするかが難しいです。今回はプログラミング経験はあるけど、C#やmodの知識が全く無かった頃の自分を思い出して書いています。

BeatSaberのmod製作記事を書こうと思ったこともありますが、ハードルが高いんですよね、最初の一段が。超えられる人は作れるけど、超えられないと挫折してる気がします。

その点、ChroMapperは本体側の改造の説明から始められるので、順番に解説できます。なるべく一段一段の高さが高くならないようにしたつもりです。今回の内容は基本中の基本ですが、大枠の部分は分かってもらえたかと思います。

プラグインやmodは改造の対象となるChroMapperやBeatSaberがあるので、まずはそれらのコードや動きを読み取る力が必要です。
ただ、プラグインもmodも普通のプログラムと比べてイレギュラーなことをしているので、ネットで調べてもズバリなやり方が出てきません。
このあたりが、0から機能を作り上げていく通常のプログラミングと違うところで、一番難しいところかもしれません。
唯一の参考は他のプラグインやmodのソースコードなので、オープンソースに感謝しつつ、遠慮なく他のコードを参考にして、流用もライセンスに従ってすれば良いと思います。

まずは既存のプラグインやmodを改造してみる、プラグイン化までしなくてもUnity上でChroMapperを改造してみる、でも良いと思います。
できる所から始めることが大切です。作って、エラーで動かなくて、解決方法を調べる、の繰り返しで身についてきます。
あとはUnityの理解のために入門書でも読んで、本をなぞる形でも良いので簡単なゲームでも作ってみると、より理解が深まると思います。

ChroMapperのプラグインはBeatSaberのmodを作る際にも良い参考になります。私もCameraMovementのプラグインを作ったことで理解が深まって、BeatSaberのmodも色々作れるようになりました。

今回の記事を読んで、一人でもChroMapperのプラグインやBeatSaberのmodを作る人が増えたら良いなと思います。

※ UIのレイアウト設計はUnity上で行っています。ちなみに、UIを作る場合はLayout Groupをなるべく使って、Rect Transformの数値調整はなるべく避けたほうが良いです。私のプラグインのUIは真逆を行ってるので参考にしないほうが良いかもです。特にUnityも使わずに数値だけで調整すると、ビルドとChroMapperでの確認を死ぬほど繰り返して位置調整が大変です😅

BeatSaber用mod製作について

一番最初にBeatSaberのmodとChroMapperのプラグインは非常に似ていると書きましたが、実際のところ殆ど同じです。

[Plugin]属性のクラスが起点で[Init]属性のメソッドが初期化で呼ばれるとか全く同じですね。BeatSaberもUnity作られていますので、殆ど同じようなことをしてmodを作って行きます。

違いは、ChroMapperは本体のUnityプロジェクトを見ることができますが、BeatSaberはできないことです。ではどうするかと言うと、デコンパイラと呼ばれるツールを使ってBeatSaber本体のDLLを解析しながら調査します。
デコンパイラはいくつかありますが、dnSpyと呼ばれるツールが有名です。dnSpyの使い方は検索するといっぱい出てきますが、直接改造する説明が多く、解析用途で分かりやすいのは以下のサイトです。
Mount&blade II Bannerlord @wiki MOD制作 > ソースコード解析
他のゲームでの説明ですが、BeatSaberの場合はBeat Saber_Data\Managedの
DLLファイルを読み込ませる部分が違うだけです。

ただ、デコンパイラだとUnityエディタのHierarchyのような、オブジェクトのツリー構造はわかりません。なので、HierarchyやオブジェクトのInspectorを見たい場合はRuntimeUnityEditorと呼ぶツールがあります。BeatSaberを起動中にgキーを押すとHierarchyやInspectorが見られます。マッパーの人でもHeckでEnvironmentをいじるときに、idに設定するゲームオブジェクトを探すのに使ったりするので、知っている人も多いかもしれません。
BeatSaber(BSIPA)で動作するものをビルドしたのはBSMGで貼られています

このあたりのツールを駆使して調べながら作っていくので、どうしても難易度が上がります。なので、特に最初のうちはソースコードが公開されているmodで、自分が実装したい機能を搭載しているものを参考にしながら作った方が良いです。

あと、ChroMapperのプラグインと違うところはライブラリが充実していることです。多くのBeatSaber用のmodが依存しているライブラリにSiraUtilがあります。これはZenjectと呼ばれるBeatSaber本体で使われているDI用のフレームワークを便利に使うライブラリです。
具体的には、ChroMapperではSceneManagerでシーン切替を判断して、FindObjectsOfTypeAllでコンポーネントを探して、ゲームオブジェクトにAddComponentしていましたが、ここがSiraUtil(Zenject)の機能を使う感じに変わります。ChroMapperのやり方でももちろんできますが、インスタンスの管理や他のSiraUtilを使ったプラグインを参照したりするのに便利なので、私のmodは全てSiraUtilを使う形式にしています。

また、UI関係は基本的にBeatSaberMarkupLanguage(BSML)ライブラリを使うことになると思います。BSMLと呼ぶ独自言語を使ってUIを設計します。なかなか癖が強く(BSMLの癖というより、UnityのUIの癖)思ったようなレイアウトにするのに苦労するかもしれません。X(Twitter)で言うことを聞かないBSMLへのModderの嘆きがたまに見られます。Unityエディタでレイアウト設計できないので仕方がないのですが、特定のメニューではHotReloadでBeatSaber起動中に.bsmlを調整できるので、複雑なメニューを作る場合は参考にしてください。ビルド&BeatSaber起動の繰り返し地獄は緩和されると思います。

それと、今回アクセス権を無視してアクセスするために、HarmonyLibのTraverseを使いましたが、BeatSaberの場合はもっと便利なものがあります。
BepInEx.AssemblyPublicizer.MSBuildと言うライブラリで、.csprojで対象のアセンブリを<Publicize>True</Publicize>すると、全てのアクセス権をpublic化してくれます。ちなみに、modフレームワークのBepInEx(BSIPA)上で動作するためChroMapperでは使えません。(ちなみに、Ver0.4.2だと動作しないので、一つ前の0.4.1を使用してください)

その他、詳しいことはライブラリのマニュアルとか、他のmodを参考にしてください。あとは、デンパ時計さんの下記の記事も参考になると思います。

私の下記Wikiでもまとめていますので参考にしてください。
BeatSaberのmod製作参考

それでは、非常に長い記事(コードも入れると4万字超えてます😵)でしたが、ここまで読んで頂きありがとうございます。

本記事のライセンス

本記事はChroMapperのソースコードを掲載しているので、ChroMapperのライセンス (GNU General Public License v2.0)を継承し適用します。
https://github.com/Caeden117/ChroMapper/blob/master/LICENSE