前回のmod作成記事が好評だったので、今回はもうちょっと込み入った内容で、続編を書いてみようと思います。ちょっとむずかしくなりますが、がんばってついてきてください!
前回記事の内容を前提としているので、まだ前回の記事を読んでいない方、読んだけど完全に忘れた、という方は下記を読まれることをおすすめします。
なお本記事(と前回)のコードでは、極力説明を省略するため、効率的でなかったり、あまり褒められた書き方ではなかったりする部分が見受けられると思いますが、大目に見ていただけるとありがたいです。
前回記事:
fanbox post: creator/53638632/post/2405813
今回は画像を読み込むmodを作ります。最終的には、その画像をノーツに張り付けて遊べるようにしたいと思います。
前回と同様にプロジェクト作成からやっていきますが、細かい操作は前回を参照してください。mod名は何にしましょうか…いつも悩むところです。ノーツを装飾するmodになるので、NotesDecal(ノーツデカル)にしましょうか。
プロジェクトを作成したら、ソリューションの下のプロジェクト名を右クリックして、「Beat Saber Modding Tools」→「Set Beat Saber Directory...」と選択します。
その後、Visual Studio 2019を一旦終了させて、再度起動し、最近開いた項目(R)のリストからNotesDecal.sln(modの名前)をクリックして開きます。
右のファイル一覧から「NotesDecalController.cs」をダブルクリックして開き、左のエディタ内の「Monobehaviour Messages」という行の左にある[+]をクリックします。ここまでは前回と同じですね。
最終目標は、ノーツに画像を張り付けることですが、いきなり完成形を作ろうとすると大変です。小さな部分ごとにプログラムを書いて、ひとつずつ動くことを確認しながら作っていきましょう。
まずは画像を読み込む部分を作る…前に、まず画像を用意します。下記のZIPファイルをダウンロードして、中身の画像ファイルをビートセイバーのフォルダにコピーしてください。
ビートセイバーのフォルダは、Steam版のデフォルトインストール先の場合は
C:\Program Files (x86)\Steam\steamapps\common\Beat Saber
となりますが、環境により異なります。
まず、画像を読み込むコードです。
private void Start() の { } の中に以下を入力(コピー&ペースト)してください。
----
StartCoroutine(InitCoroutine());
IEnumerator InitCoroutine()
{
Material noGlowMat = null;
while (noGlowMat == null)
{
noGlowMat = Resources.FindObjectsOfTypeAll().Where(m => m.name == "UINoGlow").FirstOrDefault();
yield return null;
}
gameObject.AddComponent();
gameObject.AddComponent().SetRadius(0f);
string fileName = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "NotesDecal.png");
Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false);
texture.wrapMode = TextureWrapMode.Clamp;
texture.filterMode = FilterMode.Point;
texture.LoadImage(File.ReadAllBytes(fileName));
GameObject image = new GameObject("Image");
image.transform.SetParent(transform);
RawImage rawImage = image.AddComponent();
rawImage.GetComponent().sizeDelta = Vector2.one;
rawImage.material = new Material(noGlowMat);
rawImage.texture = texture;
image.transform.position = new Vector3(0, 0, 0);
image.transform.eulerAngles = new Vector3(0, 0, 0);
image.transform.localScale = new Vector3(1, 1, 1);
}
----
ここで、いくつかエラーを表す赤の波線が表示されると思います。一つずつ解決していきましょう。
CurvedCanvasSettingsを右クリックして「クイック アクションとリファクタリング...」を選択、次に表示されるメニューで「using HMUI;」を選択。
Pathを右クリックして「クイック アクションとリファクタリング...」を選択、次に表示されるメニューで「using System.IO;」を選択。
LoadImageは後回しにします。
RawImageを右クリックして「クイック アクションとリファクタリング...」を選択、次に表示されるメニューで「using UnityEngine.UI;」を選択。
前回も書きましたが、この作業は、今コードを書いているファイルの外に存在するものに対して、どこに存在するのか指定する作業を行っています。
既にプロジェクトに登録されているモジュール(dll)の中にあるものは、このように右クリックから選択するだけで場所の指定ができるのですが、未登録のモジュール内にあるものに対しては、手動で登録作業を行ってやる必要があります。さっき後回しにしたLoadImageがそれにあたります。
まず、右のファイル一覧の上にある「参照」を右クリックして「参照の追加(R)...」を選択します。
次の画面で「参照(B)...」ボタンを押します。
ファイル選択画面が開くので、
C:\Program Files (x86)\Steam\steamapps\common\Beat Saber\Beat Saber_Data\Managed
(Steam版デフォルトインストール先の場合)
の中にあるUnityEngine.ImageConversionModule.dllを選択して、「追加」ボタンを押します。
このようにUnityEngine.ImageConversionModule.dllが追加(チェック)されました。「OK」ボタンを押して閉じます。
これでLoadImageの赤波線も消えました。大丈夫ですか?ついてこれてます?
ここで一旦動作確認を行ってみます。ここまでの作業がミスなく行えていれば、エラーなくビルドが行えるはずです。
画面上のメニューから「ビルド」→「ソリューションのビルド」を選択すると、画面下の欄に「ビルドを開始しました...~」というメッセージが出てきます。最終的に「========== ビルド: 1 正常終了、0 失敗、0 更新不要、0 スキップ ==========」
または
「========== ビルド: 0 正常終了、0 失敗、1 更新不要、0 スキップ ==========」
と表示されれば、エラーなくビルドが成功しています。
ビルドが成功すると自動的にビートセイバーのPluginsフォルダに、今作成したmodのdllがコピーされるようになっています。早速、ビートセイバーを起動して動作確認してみましょう。
このようにサングラスがメニュー画面の地面に表示されていれば成功です。
StartCoroutine(InitCoroutine());
IEnumerator InitCoroutine() { }
Material noGlowMat ...
この部分は、処理待ちを行うためのコードです。このmodが動きだした時点では、画像を表示するために必要なもの(マテリアル)が存在しないので、それが生成されるまで待っています。
gameObject.AddComponent();
gameObject.AddComponent().SetRadius(0f);
画像を表示するための準備です。
string fileName = ... ~ texture.LoadImage
画像ファイルを読み込んでいます。この時点では、まだデータを読み込んだだけで、ゲーム内に見えるオブジェクトとしては存在しません。
GameObject image ~ rawImage.texture = texture;
上で読み込んだデータをもとに、ゲーム内の画像オブジェクトを作成しています。
image.transform...
作成した画像オブジェクトの位置やサイズを設定しています。
せっかくなので、他の画像を表示したり、位置を動かしてしてみましょう。さきほど記述したコードの中で、以下の部分を変更するとカスタムできます。
string fileName = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "NotesDecal.png");
読み込む画像のファイル名を指定しています。ビートセイバーのフォルダ内にある"NotesDecal.png"を読み込むように記述しています。フルパスでファイル名を指定することも可能です。パスの区切り文字「\」を書く場合は「\\」のように2つ書く必要があります(後述例)。
image.transform.position = new Vector3(0, 0, 0);
ここは画像を表示する位置を指定しています。数字は順に、左右位置、上下位置、奥行き位置を表し、(0, 0, 0)は中心になります。前回も書きましたが、小数を指定する場合は、1.5fのように末尾にfを付けます。
image.transform.eulerAngles = new Vector3(0, 0, 0);
ここは画像の角度を指定しています。(0, 0, 0)は角度の変更なしを表し、正面向きになります。
image.transform.localScale = new Vector3(1, 1, 1);
ここは画像の大きさを指定しています。数字は順に横、縦、奥行きを表します。(画像の場合、奥行きは無視)
試しに下記のように変更して、ビルドしてみてください。
----
string fileName = "C:\\Windows\\Web\\Wallpaper\\Windows\\img0.jpg";
(これはWindowsのデフォルト壁紙ですが、このファイルが存在しない場合は、適当な画像ファイルを指定してください)
----
image.transform.position = new Vector3(0, 1, 1);
image.transform.eulerAngles = new Vector3(0, 0, 0);
image.transform.localScale = new Vector3(2, 1, 1);
(位置をすこし上と奥に、サイズを横長に)
----
これをビートセイバーで動かすとこのようになります。
なんだかおかしいですね?たしかに位置は指定通りになっているのですが、画像が透けてしまっているようです。
実際には「透けている」というよりは、裏のUI要素が貫通して描画される状態になっています(ノーツなどのオブジェクトは透けません)。これには理由があり、この画像の描画優先順が、奥にあるメニュー要素よりも低い状態になっているのが原因です。
今回の目的である、ノーツに画像を張り付ける場合についてはこの問題は起こらないので、とくに気にする必要はありません。
もしメニュー画面で画像を出すのが目的でこの問題を回避したい場合は、画像を表示する方法を変えたほうが良いのですが、今回の目的とはずれた内容になるので、とりあえず簡易的な対処法を挙げておきます。
末尾(image.transform.localScale = ...)の下に、次のコードを追加します。
----
GameObject levelDetailViewController = null;
while (levelDetailViewController == null)
{
levelDetailViewController = GameObject.Find("MenuCore/UI/ScreenSystem/ScreenContainer/MainScreen/LevelSelectionNavigationController/LevelCollectionNavigationController/LevelDetailViewController");
yield return null;
}
image.transform.SetParent(levelDetailViewController.transform);
----
これをビルドして、ビートセイバーで実行すると、以下のようになります。
透けずに表示されるようになりました。
この対処法では、画像を、裏にあるメニュー要素の一部とすることで、他のメニュー要素の裏にいかないようにしています。副作用として、このメニュー画面から抜けたときや、ゲーム中(譜面プレイ中)には、画像も見えなくなってしまいます。
一旦ここまでの内容をサンプルコードとして置いておきます。
すこし横道にそれましたが、次の内容に進みます。コードを「画像を読み込む」の内容に戻しておいてください。
次は、ノーツに画像を乗せる処理を作ります。とは言っても、まだゲーム(曲)プレイには入らず、仮の四角いオブジェクトをノーツと見立てて、これに画像を乗せる処理を作ってみます。
末尾(image.transform.localScale = ...)の下に、次のコードを追加してください。
----
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
Renderer renderer = cube.GetComponent();
renderer.material = new Material(Shader.Find("Custom/SimpleLit"));
renderer.material.color = Color.red;
cube.transform.SetParent(transform);
cube.transform.position = new Vector3(0, 1, 1);
cube.transform.localScale = new Vector3(0.4f, 0.4f, 0.4f);
GameObject newImage = Instantiate(image);
newImage.transform.SetParent(cube.transform);
newImage.transform.localPosition = new Vector3(0, 0, -0.6f);
newImage.transform.localScale = new Vector3(1, 1, 1);
----
CreatePrimitive(PrimitiveType.Cube) はキューブを作成する命令で、ノーツに見立てた立方体を出すために呼び出しています。今回は使用しませんが、例えばここをPrimitiveType.Sphereにすると球が、PrimitiveType.Planeにすると平面が出ます。
その下は、作成したキューブの色や位置を設定しています。具体的には、色を赤色に、位置を高さ1m前方1m、大きさを0.4m四方、に設定しました。
GameObject newImage = Instantiate(image) は、上で読み込んだ画像を複製しています。複数出てくるノーツに対して毎回画像を読み込む処理を書くのではなく、このように事前に作ったものを複製していくとスマートですね。
さらにその下は、複製した画像の位置とサイズを設定しています。SetParentは、画像がキューブからの相対位置(と相対スケール)で指定できるようにする指示です。そのままだと画像がキューブ内部に埋まってしまうので-0.6とすこし手前に動かすのと、画像がキューブに対してちょうどいいサイズになるよう調整しています。
ちなみに「//」から始まる緑の行は「コメント」といって、プログラムとしては無視される行です。無視される行が何のために必要なのか、と思われるかもしれませんが、これはコードの説明を書いておくために使用されます。
え?わたしのプログラムはぜんぜんコメントがないじゃないか、って?コード自体が見てすぐわかるものなら、コメントなんていらないんですよ。そんなノリでプログラムを書いているので、後で見返したときに「このプログラムを書いたのは誰だあっ」となります。真似してはいけません。
余談はさておき、ここまでできたらビルドを行って、ビートセイバーを実行してみましょう。うまくできていれば下記のようになります。
おおむねいい感じになってきましたが、地面に落ちているサングラスは複製元として使用するだけなので、非表示にしておきたいですね。上のコードを次のように修正してください。
----
image.SetActive(false);
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
Renderer renderer = cube.GetComponent();
renderer.material = new Material(Shader.Find("Custom/SimpleLit"));
renderer.material.color = Color.red;
cube.transform.SetParent(transform);
cube.transform.position = new Vector3(0, 1, 1);
cube.transform.localScale = new Vector3(0.4f, 0.4f, 0.4f);
GameObject newImage = Instantiate(image);
newImage.transform.SetParent(cube.transform);
newImage.transform.localPosition = new Vector3(0, 0, -0.6f);
newImage.transform.localScale = new Vector3(1, 1, 1);
newImage.SetActive(true);
----
SetActive という命令で、オブジェクトの有効無効(表示非表示)を設定することが可能です。
ここでは、まず image.SetActive(false); で複製元画像を非表示にしておき、複製後の画像に関しては、最後に newImage.SetActive(true); で表示するようにしています。
これをビルドすると、期待通りの動作になります。
長くなってしまったので、ここまでを前編とします。前回の記事では文字列を表示していた処理が、今回は画像になっただけなのですが、新しい操作や、対処法の説明が多くなってしまいましたね。
以下に、ここまでのサンプルコードを置いておきます。
後編では、いよいよノーツを出現させる処理に手を入れていきます。準備ができたら進みましょう!
fanbox post: creator/53638632/post/2496172