🔫

UnityとJetpackComposeでウルトラARシューティングゲームを作ろう!(Kotlin、JetpackCompose連携まとめ)

2023/12/06に公開

これを作ります(音あり推奨)

https://www.youtube.com/watch?v=K8HNfdtxZnQ

はじめに

どうも!
アルサーガパートナーズでiOSエンジニアをしているウルトラ深瀬です!
今回はUnityで作ったアプリをビューとしてAndroidネイティブアプリに組み込む際に必要になる、Kotlin、JetpackComposeとの連携あれこれを解説していきます!

ARシューティングゲーム部分はあくまでも上記を楽しく学ぶ為の題材なのでおまけ程度です。
また、今回程度の内容であればUnityオンリー、もしくはネイティブでARCoreを使うのでも良いですが、Unityとネイティブの相互連携の題材としてあえてこの構成にしています。

この記事で分かること

  • Unity(Unity as a Library)で作ったアプリをAndroidネイティブアプリ上で表示させる為の最低限の設定方法
  • UnityビューをJetpackComposeのレイアウトに組み込む方法
  • NavHostの画面A => Unityビューを載せた画面 => NavHostの画面Bの遷移方法
  • UnityとAndroidで相互に通信して値の受け渡しをする方法
  • (おまけ)Unityで弾を発射するアニメーションを実装する方法

上記の実装が今回のアプリでそれぞれどこで必要になるか

各実装を見ていく前に、それぞれの実装が必要になるシチュエーションを想像するとより理解が深まると思いますので、今回のアプリと照らし合わせて少し想像してみましょう。

まずアプリ起動時のTOP画面からボタンタップでゲーム画面に遷移したいとします。
アプリ全体のUIはJetpackComposeで作るので、画面遷移は同じくJetpackComposeのNavHostで管理しています。
Unityビューを載せた画面はライブラリの都合でActivityである必要があるので、

  • NavHost内でActivity画面への遷移

をしたいですね。

次に、やはりゲーム画面の細かいUIもJetpackComposeで作ってしまうと楽なので、

  • Unityビューを載せたActivityとJetpackComposeで作ったレイアウトを重ねる

方法も知りたいです。

今度はゲームが終了したら結果画面に戻りたいですが、結果画面もやはりJetpackComposeで作った画面でNavHost管理なので、

  • ActivityからNavHost内のcomposableな画面への遷移

も必要になります。

次に、ゲーム画面を詳しく見ていきましょう。
今回のアプリだと、ゲーム画面のUnityARビューに重ねたComposeのボタン「弾を発射」を押下したタイミングでUnity側で実際に弾オブジェクトを発射させています。そこで、

  • Android => Unity方向の通知

が必要になります。
また、弾オブジェクトが的オブジェクトに当たったのを検知して、ゲーム画面左上の「0/3ヒット」というラベルの数を更新したいです。とすると、Unity側での衝突検知イベントやどの的なのかという情報をAndroid側に知らせる必要があるので、

  • Unity => Android方向の通知

も必要になりますね。

この様に、実務でAndroidネイティブアプリの一部をリッチな3D表現にしたいなどのケースで、Unityビューを表示する場合にもこれらの連携をしたいユースケースが発生しそうだと考えられます。
それでは1つずつ実装方法を見てきましょう。

Unity(Unity as a Library)で作ったアプリをAndroidネイティブアプリ上で表示させる為の最低限の設定方法

Unity(UaaL)とAndroidネイティブの環境構築の記事は既にいくつかありますが、いかんせん情報が古くて今回結構躓きましたので特に躓いたAndroid側の設定手順を簡単に載せていきます。
より詳細な環境構築の手順に関しては別途個別の記事としても改めて載せようと思います。

Unity側の書き出し作業

下記の手順で事前にUnity側でunityLibraryとしてAndroid向けに書き出しておきます。

  • Build SettingsでExport Projectsにチェックを入れる(そうする右下の方のBuildボタンがExportボタンに変わる)
  • Build Settingsの左下のPlayerSettingsからPlayer欄の一番下のPublishing SettingsのBuild欄のCustom Main Manifestにチェックを入れる
  • BuildSettingsに戻ってExportボタンを押す。書き出し場所を選んでOKを押す。

Android側の設定作業

Unity側で書き出したフォルダがBuildというフォルダ名なので、その中にあるunityLibraryというフォルダをAndroidStudioの中のプロジェクトのルートディレクトリに配置します。(appディレクトリと同列にします)

大量のファイルなので自分は下記2箇所に.gitignoreを追加して追跡対象外にしています。

  • unityLibrary/.gitignore
/build
/src
/symbols
  • unityLibrary/xrmanifest.androidlib/.gitignore
/build

unityLibrary/build.gradleのdependenciesを下記のように修正

dependencies {
    implementation project(':unityLibrary:xrmanifest.androidlib')
    implementation fileTree(dir: project(':unityLibrary').getProjectDir().toString() + ('\\libs'), include: ['*.jar', '*.aar'])
}

unityLib内の2つのbuild.gradleのandroid{}内にnamespaceを追加。
appディレクトリの方のbuild.gradleを確認してそちらもGroovyなら同じ内容のnamespaceを、KTSならコピーした内容をGroovyの書き方に合わせた上で追加します。
(こちらの手順の必要性はAGPのバージョンによるかもしれません。7.4.2の時は不要でしたが、8.1.3だと必要でした)

  • unityLibrary/xrmanifest.androidlib/build.gradle(どちらも同じ内容を追加)
  • unityLibrary/build.gradle(どちらも同じ内容を追加)
android {
   // 今回の私の場合は'com.takamasafukase.test_ultra_shooting'でした。
    namespace 'com.ユーザー名.プロジェクト名' // 追加
}

unityLib内の下記2つのAndroidManifest.xmlファイルからそれぞれ該当箇所を削除
(こちらの手順の必要性もAGPのバージョンによるかもしれません。同様に、7.4.2の時は不要でしたが8.1.3だと消さないとエラーが出ました。)

  • unityLibrary/xrmanifest.androidlib/AndroidManifest.xml
package="com.UnityTechnologies.XR.Manifest" // 削除
  • unityLibrary/src/main/AndroidManifest.xml
package="com.unity3d.player" // 削除

appディレクトリのbuild.gradleのdependenciesに追加

  • Groovyの場合
implementation project(':unityLibrary')
implementation project(':unityLibrary:xrmanifest.androidlib')
implementation fileTree(dir: project(':unityLibrary').getProjectDir().toString() + ('\\libs'), include: ['*.jar', '*.aar'])
  • KTSの場合
implementation(project(":unityLibrary"))
implementation(project(":unityLibrary:xrmanifest.androidlib"))
implementation(fileTree(mapOf("dir" to "/Users/fukase/Development/Test_Ultra_AR_Shooting/unityLibrary/libs", "include" to listOf("*.jar", "*.aar"))))

settings.gradleに追加

  • Groovyの場合
include ':app', ':launcher', ':unityLibrary', 'unityLibrary:xrmanifest.androidlib'
  • KTSの場合
include(":app", ":unityLibrary", "unityLibrary:xrmanifest.androidlib")

gradle.propertiesに追加

unityStreamingAssets=.unity3d

app/src/main/res/values/strings.xmlに追加

<string name="game_view_content_description">Game view</string>

MainActivity上で表示

最後に、とりあえず一旦表示させてみましょう。
MainActivityを下記の様に編集します。

import android.os.Bundle
// UnityPlayerActivityのimportを追加
import com.unity3d.player.UnityPlayerActivity

// 継承をUnityPlayerActivityに変更↓
class MainActivity : UnityPlayerActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
  }
}

ビルドしてみて、設定が上手くいっていればこれだけでARカメラが起動してUnityビューを表示できます。(何かオブジェクトをヒエラルキー上に置いていたら表示されると思います。)
今回はARなのもあってかかなり簡単に表示できました。
(3Dの場合はまた少し手順が違うかもしれないので、下記のわかりやすい記事をご参考ください)

UnityビューをJetpackComposeのレイアウトに組み込む方法

今回のアプリではUnity部分はAR用のカメラなので全画面、その上に背景を透明にしたComposeのビューを重ねています。(弾の発射ボタンやヒット数の表示ラベル、画面中央の照準画像など)

今回はGame〇〇という名前にするので、GameActivityとactitivity_game.xml、GameScreenのファイルをそれぞれ追加します。

まずGameScreenとして重ねたいレイアウトをComposeで作ります。

@Composable
fun GameScreen() {
    Surface(
        modifier = Modifier.fillMaxSize(),
	// AR用のカメラビューを隠さない様に背景を透明に設定
        color = Color.Transparent,
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .padding(top = 64.dp, start = 32.dp, end = 32.dp, bottom = 132.dp)
            ) {
            Row(
                horizontalArrangement = Arrangement.SpaceBetween,
                modifier = Modifier
                    .align(Alignment.TopCenter)
                    .fillMaxWidth()
            ) {
                Text(
                    // 当たった的の件数を表示
                    text = "${"後で良い感じに実装"} / 3 ヒット",
                    fontSize = 28.sp,
                    fontWeight = FontWeight.Black,
                    color = Color.Black,
                )
                Button(
                    onClick = {
                        // 音声の再生など
                    },
                    colors = ButtonDefaults.buttonColors(
                        containerColor = Color.White,
                        contentColor = Color.Black,
                    )
                ) {
                    Text(
                        text = "そして輝く",
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Black,
                    )
                }
            }
            // 中央の照準アイコン
            Image(
                painter = painterResource(id = R.drawable.pistol_sight),
                colorFilter = ColorFilter.tint(Color.Green),
                contentDescription = "Pistol sight",
                modifier = Modifier
                    .size(size = 100.dp)
                    .align(Alignment.Center)
                    .offset(y = (38).dp)
            )
            Button(
                onClick = {
                    // Unityとの通信処理など
                },
                colors = ButtonDefaults.buttonColors(
                    containerColor = colorResource(id = R.color.xmas_red),
                    contentColor = Color.White,
                ),
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .fillMaxWidth()
            ) {
                Text(
                    text = "弾を発射",
                    fontSize = 28.sp,
                    fontWeight = FontWeight.Bold,
                )
            }
        }
    }
}

activity_game.xmlを次の様に編集。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <FrameLayout
        android:id="@+id/unity"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>

GameActivityを次の様に編集します。
※Composeを使うので、UnityPlayerActivityではなくComponentActivityを継承している点に注意してください。UnityPlayerActivityだとComposeのライフサイクルと合わずにエラーが出たりして大変でした。

class GameActivity : ComponentActivity() {
    private var unityPlayer: UnityPlayer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        unityPlayer = UnityPlayer(this)

        setContentView(R.layout.activity_game)

        // FrameLayoutにUnityViewを追加
        val frameLayout = findViewById<FrameLayout>(R.id.unity)
        frameLayout.addView(
            unityPlayer?.rootView,
            FrameLayout.LayoutParams.MATCH_PARENT,
            FrameLayout.LayoutParams.MATCH_PARENT
        )

        // ComposeViewを作成してFrameLayoutに追加
        val composeView = ComposeView(this).apply {
            setContent {
		// ここに先ほどComposeで作ったGameScreenを重ねる
                GameScreen()
            }
        }
        frameLayout.addView(
            composeView,
            FrameLayout.LayoutParams(
                FrameLayout.LayoutParams.MATCH_PARENT,
                FrameLayout.LayoutParams.MATCH_PARENT
            )
        )

        // UnityPlayerにフォーカスを合わせる
        unityPlayer?.requestFocus()
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        unityPlayer?.windowFocusChanged(hasFocus)
    }

    override fun onResume() {
        super.onResume()
        unityPlayer?.resume()
    }
}

app/src/main/java/AndroidManifest.xmlのMainActivityの下に下記を追加。GameActivityを登録します。

<activity
android:name=".GameActivity"
android:parentActivityName=".MainActivity" />

今回はJetpackComposeのNavHostで全体の遷移を管理したいとします。

下記の構成になります。

  • MainActivity上でRootComposeを表示
    • RootComposeの中でNavHostを定義
      • TopScreen(Composeでレイアウト作成)
        • ボタンタップでGameActivityへ遷移
      • GameActivity(UnityビューとComposeレイアウトのハイブリット)
        • 特定のイベント(的が3つ当たった)でResultScreenに遷移
      • ResultScreen(Composeでレイアウト作成)
        • ボタンタップでTopScreenに遷移

Unityビューを載せる部分だけはActivityが必要なので(GameActivity)、NavHostの中でActivityへ遷移する方法と、ActivityからNavHostのCompose画面に遷移する方法の2つを見ていきます。

まずはActivityへの遷移です。

MainActivityを編集します。
継承をUnityPlayerActivityからComponentActivityに差し替え。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
	super.onCreate(savedInstanceState)
	setContent {
	    Test_Ultra_AR_ShootingTheme {
		Surface(
		    modifier = Modifier.fillMaxSize(),
		    color = MaterialTheme.colorScheme.background
		) {
		   // この後作ります。
		    RootCompose()
		}
	    }
	}
    }
}

RootComposeを追加してNavHostの遷移ルートを定義

@Composable
fun RootCompose() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "top",
    ) {
        composable("top") {
            TopScreen(
                onTapStartButton = {
		   // ボタンタップでゲーム画面に遷移
                    navController.navigate("game")
                }
            )
        }
	// ここをcomposableではなくactivityにする
        activity("game") {
	   // ここでGameActivityを設定
            activityClass = GameActivity::class
        }
        composable("result") {
            ResultScreen(
                onTapToTopButton = {
                    navController.navigate("top")
                }
            )
        }
    }
}

※TopScrrenとResultScreenも上記の感じでボタンタップで遷移できる様に適当にComposeで作っておいてください。

上記でComposeの画面からUnityビューを載せたActivity(+Composeを重ねたハイブリット)への遷移はできました。

ActivityからNavHostのCompose画面に遷移

次に、Unityビューを載せたActivityからComposeの画面へNavHostで遷移する方法です。
この遷移は主に次の流れで実現します。(やり方はいくつかあるかもしれませんが)

  • LocalBroadcast(iOSでいうNotificationCenter)を使用して、ゲーム画面からMainActivity上のRootComposeに通知を送信。
  • RootCompose内で上記の通知を受信し、NavHostで結果画面に遷移
  • MainActivity上にGameActivityが重なったままなのでGameActivityをfinish()で閉じる

今回のアプリではゲーム画面内で3つ的がすべて当たったという通知をUnityから受け取った契機で、ゲーム画面から結果画面(ResultScreen)へ遷移させています。
尺の都合でその辺りの細かい処理は割愛してしまうので、一旦はGameScreen上のボタンタップなどを遷移発火イベント用のコールバックとしてGameActivity内で受け取れる様にしましょう。

@Composable
fun GameScreen(
    // この記事内でActivityからNavHost内でCompose画面への遷移を試す為に追加
    onTapSomeButton: () -> Unit,
) {

// 省略

   Button(
	onClick = {
	    // コールバックを呼び出し
	    onTapSomeButton()
	},
    ) {
	Text(
	    text = "結果画面に遷移",
	)
    }
    
// 省略

}

そして、GameActivity上でそのGameScreenのコールバック内に下記の記述を追加します。

class GameActivity : ComponentActivity() {

   // 省略

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

       // 省略

        val composeView = ComposeView(this).apply {
	    setContent {
		GameScreen(
		    onTapSomeButton = {
			// 通知を送信して、MainActivity内のNavHostでresult画面に切り替える
			val intent = Intent("NAVIGATION_EVENT")
			intent.putExtra("destination", "result")
			LocalBroadcastManager.getInstance(context).sendBroadcast(intent)

			// 上記だけだとこのActivityがMainActivity上に被さったままでresult画面が見えないのでGameActivityを終了させる
			finish()
		    }
		)
	    }
	}
       // 省略

    }
    // 省略
}

次に、RootComposeの中で上記の通知を受信して、NavHost内で結果画面に遷移させます。

@Composable
fun RootCompose() {
    val navController = rememberNavController()
    val context = LocalContext.current

   // 通知受信時の処理を定義
    val broadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            val destinationNameText = intent?.getStringExtra("destination")
            if (destinationNameText != null) {
                // 通知で受け取ったdestinationに遷移
                navController.navigate(destinationNameText)
            }
        }
    }
    DisposableEffect(Unit) {
        // 通知受信時の処理を登録
        LocalBroadcastManager.getInstance(context).registerReceiver(
            broadcastReceiver, IntentFilter("NAVIGATION_EVENT")
        )

        // onDisposeで通知受信時の処理を解除
        onDispose {
            LocalBroadcastManager.getInstance(context).unregisterReceiver(
                broadcastReceiver
            )
        }
    }

    NavHost(
        navController = navController,
        startDestination = "top",
    ) {
        composable("top") {
            TopScreen(
                onTapStartButton = {
                    navController.navigate("game")
                }
            )
        }
        activity("game") {
            activityClass = GameActivity::class
        }
        composable("result") {
            ResultScreen(
                onTapToTopButton = {
                    navController.navigate("top")
                }
            )
        }
    }
}

これで、Unityビューを載せたゲーム画面からComposeの結果画面にNavHost遷移で戻ってくることができました。

UnityとAndroidで相互に通信して値の受け渡しをする方法

Unity ⇒ Android方向の通知

Android側のコード

Unityからアクセスするオブジェクトとメソッド、interfaceを定義。
このファイルはMainActivityとかと一緒にappディレクトリ内に配置します。

// Unityからアクセスするオブジェクト
object UnityToAndroidMessenger {
   interface MessageReceiverFromUnity {
      fun onMessageReceivedFromUnity(message: String)
   }
   var receiver: WeakReference<MessageReceiverFromUnity>? = null

   // Unityから呼び出すメソッド
   fun sendMessage(message: String) {
      receiver?.get()?.onMessageReceivedFromUnity(message)
   }
}

上記のinterfaceをViewModelなどで継承してメソッドを実装、値を取り出す。

// 先ほど定義したinterfaceを継承
class SomeViewModel() : UnityToAndroidMessenger.MessageReceiverFromUnity {

   init {
      // 自身をreceiverとして弱参照で登録
      UnityToAndroidMessenger.receiver = WeakReference(this)
   }

   // interfaceのメソッドを実装して値を取り出す
   override fun onMessageReceivedFromUnity(message: String) {
      // Unityから受け取った値(文字列)を使ってやりたい処理を書く
   }
}

Unity側のコード

Android側で定義したオブジェクトにアクセスしてメソッドを呼び出し、文字列を受け渡す。
※プロジェクト名やオブジェクト名はご自身の環境と定義した内容に合わせて下さい。

private void SendMessageToAndroid(string message)
{
   // Android側で定義したオブジェクトにアクセス(プロジェクト名やオブジェクト名はご自身の環境と定義した内容に合わせて下さい)
   AndroidJavaObject unityToAndroidMessenger = new AndroidJavaObject("com.takamasafukase.test_ultra_ar_shooting.UnityToAndroidMessenger");
   // Android側で定義したメソッドを呼び出して、任意の文字列を受け渡す
   unityToAndroidMessenger.Call("sendMessage", "ここに渡したい文字列を書く");
}

Android ⇒ Unity方向の通知

Unity側のコード

Android側から呼び出すメソッドを定義(publicにする)

// Android側から呼び出すメソッドなのでpublicにする
public void OnReceiveMessageFromAndroid(string message)
{
     // Androidから受け取った値(文字列)を使ってやりたい処理を書く
}

Android側のコード

任意の場所でUnityPlayer.UnitySendMessageを使って通知を送る
※第一引数のGameObject名は、スクリプトのファイル名やクラス名では無いので注意。
そのスクリプトがAddComponentで追加されている、Unityエディタのヒエラルキー上のオブジェクトの名前です。

private fun someFunction() {
   UnityPlayer.UnitySendMessage(
	 // 先ほど定義したメソッドのスクリプトがAddされているGameObjectの名前を指定する
	 "GameObject名を記載",

	 // 定義したメソッド名
	 "OnReceiveMessageFromAndroid",

	 // 受け渡したい任意の文字列
	 "ここに渡したい文字列を書く"
   )
}

通知の応用編

通知で送る文字列は汎用性を上げる為にidとして使って個別の処理を行わせたり、あるいはjson文字列として複数の値をまとめてやりとりする場合が多いと思います。
その場合、アプリ内で使いやすくするために双方向用でそれぞれ〇〇Messageの様な構造体を作ってjsonと変換すると便利になります。
Unity側ではJsonUtilityの ToJson(文字列)FromJson<構造体などの型名>(JSON文字列) が使えます。
以下は一例です。

双方向それぞれのメッセージ構造体を作成

// Android => Unity方向へのメッセージ送信の為の構造体
internal struct AndroidToUnityMessage
{
    public EventType eventType;
    public AndroidToUnityMessage(EventType eventType)
    {
        this.eventType = eventType;
    }
    public enum EventType{
        someEvent1,
        someEvent2
    }
}

// Unity => Android方向へのメッセージ送信の為の構造体
internal struct UnityToAndroidMessage
{
    public EventType eventType;
    public UnityToAndroidMessage(EventType eventType)
    {
        this.eventType = eventType;
    }
    public enum EventType
    {
        someEvent1,
	someEvent2
    }
}	

相互にJSON<=>Structの変換を行う

    private void SendMessageToAndroid(UnityToAndroidMessage message)
    {
        AndroidJavaObject unityToAndroidMessenger = new AndroidJavaObject("com.takamasafukase.test_ultra_ar_shooting.UnityToAndroidMessenger");
        // 構造体からJSON文字列に変換
        string jsonStringMessage = JsonUtility.ToJson(message);

        unityToAndroidMessenger.Call("sendMessage", jsonStringMessage);
    }
	
    public void OnReceiveMessageFromAndroid(string jsonStringMessage)
    {
        // JSON文字列から構造体に変換
        AndroidToUnityMessage message = JsonUtility.FromJson<AndroidToUnityMessage>(jsonStringMessage);

        switch (message.eventType)
        {
            case AndroidToUnityMessage.EventType.someEvent1:
		// やりたい処理
                break;
            case AndroidToUnityMessage.EventType.someEvent2:
		// やりたい処理
                break;
        }
    }

Kotlin側も同様に、
Json.decodeFromString<型名>(jsonString)
Json.encodeToString(jsonString)
などを用いてJSON<=>データクラスの相互変換などを行うと便利かと思います。

(おまけ)Unityで弾を発射するアニメーションを実装する方法

こちら完全に自己流且つUnity初心者なので恐縮ですが、少し紹介します。

Unityエディタ上の設定は使わずにスクリプトだけでアニメーションを実装したので、コピペでも簡単に試していただけると思います。

やりたい挙動としては下記の通りです。

  • 弾用のオブジェクトとしてSphere(球体)を使用
  • 発射前に生成して初期位置としてARカメラの位置に設置(画面にぎり映らない少し手前側)
  • 発射時点の画面中央に向かって10メートルを2秒間で到達する様に発射
  • 2秒後に自動的に消滅させる

上記を踏まえた上で、まずは発射済みの弾の情報を管理するための構造体を定義します。

internal struct ShotBullet
{
    internal int id; // 弾を識別するためのid
    internal float shotTime; // 発射時点の時間(2秒経過を判別する為に保持)
    internal Vector3 targetDirection; // 発射方向
    internal GameObject bullet; // 実際の弾(Sphere)のGameObjectのインスタンス

    internal ShotBullet(int id, Vector3 targetDirection, GameObject bullet)
    {
        this.id = id;
        shotTime = Time.time;
        this.targetDirection = targetDirection;
        this.bullet = bullet;
    }
}

その上で全体としては下記のコードになります。(弾の発射に関連する箇所のみ)

public class ARController : MonoBehaviour
{    
    private Camera cam;
    private List<ShotBullet> shotBullets = new();
    private int nextBulletId = 1;
    // 2秒で10メートル移動するための速度を計算(5メートル/秒)
    private float distancePerSecond = 10f / 2f;

   // 起動時の処理
    private void Awake()
    {
        // カメラのインスタンスを変数に格納
        cam = Camera.main;
    }

   // 毎フレームの更新で自動的にUnityによって呼び出されるメソッド
    private void Update()
    {
        // 発射済みの弾があれば移動(座標の更新)と2秒経過チェックをする
        foreach (var shotBullet in shotBullets)
        {
            // targetDirectionに沿って移動
            Vector3 moveDirection = shotBullet.targetDirection.normalized * distancePerSecond * Time.deltaTime;
            shotBullet.bullet.transform.Translate(moveDirection);

            // 2秒以上経過した弾を削除
            if (Time.time - shotBullet.shotTime > 2f)
            {
                if (shotBullet.bullet != null) Destroy(shotBullet.bullet);
                shotBullets.Remove(shotBullet);
            }
        }
    }

    // ピストルの弾を発射
    public void ShootBullet(string message)
    {
        // 弾オブジェクトとして使う球体を生成
        GameObject sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);

        // スケールを調整
        sphere.transform.localScale = new Vector3(0.05f, 0.05f, 0.05f);

        // 発射のスタート地点としてカメラ位置に移動
        sphere.transform.Translate(cam.transform.position);

        // 発射済みの球を管理する為の構造体を生成
        ShotBullet newBullet = new ShotBullet(
            id: nextBulletId++,
            targetDirection: cam.transform.forward,
            bullet: sphere
        );

        // 発射済みの弾リストに格納
        shotBullets.Add(newBullet);
    }
}

ざっくりの流れとしては下記になります。

ShootBulletメソッド内

  • 球体を生成してカメラ位置に設置。
  • ShotBullet構造体を生成して、一意の識別idと目的地座標、球体のインスタンスを格納します。この時、ShotBulletの内部ではその時の発射時刻も保持されます。
  • 生成したShotBullet構造体をshotBulletsという発射済みの弾の管理用配列に格納。

Updateメソッド内(これは毎フレームの更新で自動的にUnityによって呼び出されます)

  • 発射済みの弾があれば移動(座標の更新)
  • 発射時刻から2秒経過している弾があれば削除

まとめ

いかがでしたでしょうか。
比較的マニアックな内容を取り上げた感じになりましたが、案件によってはiOS/Android共に部分的にUnityのビューを組み込むユースケースもあるのではないかと思います。
その時には手段の1つとして持っておくと良い提案に繋がるかもしれませんし、
個人でゲームを作りたいと言う方にとっても、今回の内容が少しでも役に立てば嬉しいです!

さて、明日のアドベントカレンダーは私に引き続きiOSエンジニアの@soyaasakura さんの登場です!
お楽しみに!🎉🎄

Arsaga Developers Blog

Discussion