今回は、ビートセイバーの動作を変えるmodを作るとき、わたしがどのような作業を行っているのか書いてみます。
以前のmod作成解説では、シンプルに丸写しでmodが作れるように解説を書きましたが、「こう書けば動作が変わる」の「こう書く」の部分をどうやって見つけるのか、については触れていませんでした。今回の記事では、その部分について書いていこうと思います。
今回はビートセイバーの中身を見るため下記のソフトを使います。
https://github.com/dnSpy/dnSpy/releases/latest
詳しい使い方は解説しませんが、dnSpy-net-win64.zipを展開して、中のdnSpy.exeを起動、メニューから File → Open... で
C:\Program Files (x86)\Steam\steamapps\common\Beat Saber\Beat Saber_Data\Managed\Main.dll
を開くと、ビートセイバー内のコードを検索できる状態になります。
たとえばRawScoreWithoutMultiplierで検索するとこんな感じ。
float num = 1f - Mathf.Clamp01(cutDistanceToCenter / 0.3f);
cutDistanceRawScore = Mathf.RoundToInt(15f * num);
ノーツを切ったときの距離スコア(0-15)が、上記のコード(計算式)になっている、ということがわかります。
NalulunaFlyingScore、ビートセイバーのノーツカットスコアに色を付けたり、昔のFF風のダメージ表示風に表示したりするわたしのmodですが、ビートセイバーの1.13.4の仕様変更で、数字の跳ねる挙動を変えざるを得なくなってました。
具体的に言うと、以前のバージョンでは、跳ねる数字が各桁ごとに時間差で飛んでいたのが、今のバージョンでは全桁まとまって飛ぶようになっています(FF4,5からFF6の飛び方に変わってます)。これは、1.13.4以降、時間差で飛ばすと、なぜか表示されるスコアがおかしくなってしまう問題が出たためです。
これを直すため、そのうち一から作り直そうと思っているのですが、今回は、このmodを作る過程の一部をご紹介します。
昔の挙動
今の挙動
よく使う機能をまとめた自分用のテンプレートを使ってプロジェクトを作成します。一から書くと面倒な設定画面や、どのmodにも必要になりそうな定型処理が既に入ったところからスタートできるようにしてます。
どういった処理で、ビートセイバーがノーツカットスコアを出していたのかうろ覚えなので、そのあたりを確認するためdnSpyを起動します。適当に名前で当たりをつけて検索しましょう。"cutscore"で検索するとNoteCutScoreSpawnerというクラスを見つけました。これっぽいですね。
すこし思い出してきました。FlyingScoreEffectがカットスコアの文字や色などの制御を、(その親クラスの)FlyingObjectEffectがカットスコアの移動の制御をしています。
どこから手を付けるか悩みますが、まず、今のバージョンで変わってしまっている挙動を元に戻すのが目的なので、それを実現できるか実験してみます。通常、カットスコアが1回出るところを、時間差で(同じカットスコアを)3回出るように変えたいと思います。
NoteCutScoreSpawner.HandleNoteWasCutにパッチを当てて、カットスコアを出す処理の後に、さらに時間差で2回カットスコアを出すようにしてみました。これで一回テストします。
ああ、スクリーンショットがうまくとれません…。もっと表示間隔を短くすべきでした。消えかけていますが、最初に飛んだカットスコアが18なのに、次に飛んだカットスコアは4になっています。ここには映っていませんが、3つ目のカットスコアも4でした。やはり時間差で飛ばすと、スコアの数字がおかしくなってしまうようです。
何が起こっているかわからないので、各関数にログを仕込んで実行順を調べます。
すると…
----
InitAndPresent
SpawnFlyingScore:1
HandleSaberSwingRatingCounterDidChange
HandleSaberSwingRatingCounterDidChange
...
HandleSaberSwingRatingCounterDidChange
HandleSaberSwingRatingCounterDidFinish
InitAndPresent
SpawnFlyingScore:2
InitAndPresent
SpawnFlyingScore:3
----
2つ目のカットスコアを出す前に、SaberSwingRatingCounter(セイバーのスイング角度とスコアを管理する一時オブジェクト)が終了しているようです。
さらにSaberSwingRatingCounterの処理を追っていくと、このオブジェクトはノーツを切った瞬間(GameNoteController.HandleCut)に生成され、それから約0.4秒後に解放(Finish)されるような処理になっています。
dnSpyでカットスコアの処理を確認すると、SaberSwingRatingCounterがない場合、角度点は0点扱いになるようですね。2つ目と3つ目の点が少ないのはこのせいでしょうか。
さて、考えられる解決法の1つは、SaberSwingRatingCounterが3つ目のカットスコアが消えるまで解放されないようにする(ただし、スイング角度の計算受け付け時間は増えないように)。
もう1つは、2つ目と3つ目のカットスコア表示処理を変えて、SaberSwingRatingCounterがなくなっていても正常な値が出るようにする、が考えられます。どっちにしましょう。
とりあえず、SaberSwingRatingCounterの解放を遅らせてみました。これでテストしてみると…、あ、あれ?これでもダメ…。
dnSpyでカットスコアを出している部分を精査すると、どうも初回のスコア計算では、中心カットスコア(0-15)が足されていないみたいです。なぜ?
初回のスコア計算に、中心カットスコアを加えるように変更して、再度テストです。
普通に動くだけなのでスクリーンショットはありませんが、今度はうまくいきました。時間差の3つとも同じ数字が出るようになっています。
ただ、また別の不思議な問題が…。
バッドカットしたときに、動かない「100」というカットスコアが画面に残り続ける現象が発生しました。いったい何が起こってるんでしょう。
ログを見ると、NoteCutScoreSpawner.csの25行目を起点にして、NullReferenceExceptionが発生しているようです。
----
[CRITICAL @ 08:37:14 | UnityEngine] NullReferenceException: Object reference not set to an instance of an object
[CRITICAL @ 08:37:14 | UnityEngine] (wrapper dynamic-method) FlyingScoreEffect.FlyingScoreEffect.InitAndPresent_Patch2(FlyingScoreEffect,NoteCutInfo&,int,single,UnityEngine.Vector3,UnityEngine.Quaternion,UnityEngine.Color)
[CRITICAL @ 08:37:14 | UnityEngine] (wrapper dynamic-method) FlyingScoreSpawner.FlyingScoreSpawner.SpawnFlyingScore_Patch2(FlyingScoreSpawner,NoteCutInfo&,int,int,UnityEngine.Vector3,UnityEngine.Quaternion,UnityEngine.Quaternion,UnityEngine.Color)
[CRITICAL @ 08:37:14 | UnityEngine] NalulunaFlyingScore.HarmonyPatches.NoteCutScoreSpawnerHandleNoteWasCut+d__1.MoveNext () (at X:/NalulunaFlyingScore/HarmonyPatches/NoteCutScoreSpawner.cs:25)
----
該当のコードを確認すると以下のようになってます。ここでNullReferenceExceptionが発生するということは、flyingScoreSpawnerがnullになっているのでしょうか。
----
flyingScoreSpawner.SpawnFlyingScore(noteCutInfo, lineIndex, multiplierWithFever, noteCutInfo.cutPoint, worldRotation, inverseWorldRotation, new Color(0.8f, 0.8f, 0.8f));
----
ログにflyingScoreSpawnerの状態を表示するようにして再度テストします。
するとログはこの通り。flyingScoreSpawnerは問題ありませんでした。よく見ると、問題の箇所はビートセイバー本体内のFlyingScoreEffect.FlyingScoreEffect.InitAndPresentにあるようです。
----
[DEBUG @ 08:58:23 | NalulunaFlyingScore] {X:\NalulunaFlyingScore\Plugin.cs:23} SpawnFlyingScore:1, flyingScoreSpawner=FlyingScoreSpawner (FlyingScoreSpawner)
[DEBUG @ 08:58:24 | NalulunaFlyingScore] {X:\NalulunaFlyingScore\Plugin.cs:23} SpawnFlyingScore:2, flyingScoreSpawner=FlyingScoreSpawner (FlyingScoreSpawner)
[DEBUG @ 08:58:24 | NalulunaFlyingScore] {X:\NalulunaFlyingScore\Plugin.cs:23} InitAndPresent
[CRITICAL @ 08:58:24 | UnityEngine] NullReferenceException: Object reference not set to an instance of an object
[CRITICAL @ 08:58:24 | UnityEngine] (wrapper dynamic-method) FlyingScoreEffect.FlyingScoreEffect.InitAndPresent_Patch2(FlyingScoreEffect,NoteCutInfo&,int,single,UnityEngine.Vector3,UnityEngine.Quaternion,UnityEngine.Color)
----
dnSpyでこのInitAndPresentを確認します。この中でNullReferenceExceptionを起こしそうなのは…_saberSwingRatingCounter。またこれですか。いちおう特定するため、ログを仕込んでテストします。
テスト後のログを見ると、やはりバッドカットした場合、InitAndPresentの時点でsaberSwingRatingCounterが存在しないようです。
----
SpawnFlyingScore:1, flyingScoreSpawner=FlyingScoreSpawner (FlyingScoreSpawner)
SpawnFlyingScore:2, flyingScoreSpawner=FlyingScoreSpawner (FlyingScoreSpawner)
InitAndPresent: saberSwingRatingCounter=
----
上のほうで、SaberSwingRatingCounterの解放を遅らせる処理を入れたはずですが、バッドカットの場合はそもそも最初から存在しないのか、あるいは別のルートで解放されてしまうのでしょうか。またdnSpyで確認です。
すると…バッドカットの場合は、カットスコアを出す処理自体がキャンセルされていました。上で何も考えずに、この処理の後に2回カットスコアを出す処理を追加していましたが、これがダメでした。バッドカットの場合はこれもキャンセルしないといけません。
これでたぶんOK。テストします。
問題なさそうです。2つ目と3つ目のカットスコアの色が薄いのは気になりますが、どうせ色はmod側で制御するので、気にしないことにします。
これでやっと「カットスコアを時間差で出す」という部分が、完成しました。ここからmodの完成まで持っていくには、まだまだ作らないといけない部分がたくさん残っています。
- 1桁ずつ出すようにする
- 桁ごとに位置をずらして重ならないようにする
- 跳ねるアニメーションを入れる
- スコアごとに色をつける
- 中心カット時の下線を消す
- Proモード
- 設定 (表に見せないもの含む)
- 文字の大きさ、斜体、フォント
- スコアの色、Bloom
- スコアが飛ぶ速度、位置設定
- スコアが飛ぶ時間差設定
- 跳ねるときの初速度、重力、反発係数
- 中心カットスコアのみ表示など
挙げてみると、こんなところでしょうか。
一機能を実現する部分だけでもずいぶん長くなってしまいましたが、これでもいろいろと省いて短くした内容なんです。時間差のウエイトを入れた処理を、試しにTaskの別スレッドでやってみたらえらいことになったり、問題の原因究明にぜんぜん見当違いの部分を調べていたり、Twitterが気になって見に行ったっきり戻ってこなくなったり…これは関係ないですね。ごめんなさい。
こんな感じで、mod制作は、動かない → 原因を探るための仕込み → ビートセイバーを起動してテスト → 修正 → やっぱり動かない(最初に戻る)、の繰り返しです。ソロモードの選択まで自動で進むmod(JustTakeMeToTheSoloMode)を作ってしまうのも、わかっていただけると思います。
今回は、かなり込み入った内容で読みにくかったかもしれません。ここまでお付き合いくださって、ありがとうございました!
なるるるるな / NALULUNA
2021-09-11 23:49:50 +0000 UTCなるるるるな / NALULUNA
2021-08-27 13:59:45 +0000 UTCJAN
2021-08-27 13:17:03 +0000 UTC