跳到正文
Qiita · Unity 开发社区· 来源· solt2001·· 5 小时前AI 评分45

【Unity】使用 UniTask 制作了一款射击游戏

【Unity】UniTaskを使ってシューティングゲームを作ってみた

正文 · 原文

皆さんこんにちは。

非同期処理、していますか?

私は宗教上の理由から、コルーチンだけを使って非同期処理をしています。

今回はその宗教から足を洗うべく、UniTaskを使ってコルーチンよりも簡単に、そして扱いやすい非同期処理を学びましたので、その備忘録です。
UniTaskをこれから使う方への助けになれば幸いです。


制作環境

・Unity 6000.6.0f1
・JetBrains Rider 2026.1.4
・UniTask

プロジェクト準備

今回は縦シューティングゲームを作るため、2Dのプロジェクトで作成します。
UniTask自体は2Dでも3Dでもやることは全く変わらないので、特に影響はありません。

Unityの立ち上げが完了したら、まずはUniTaskをインストールします。
インストール方法に関しては、私よりも遥かにわかりやすい記事がたくさんありますので、そちらを参考にしてください。

参考

何をUniTaskで非同期処理をしたいのか

単純に非同期処理を使おう!というのは簡単なのですが、今回はゲームを作るうえでUniTaskを使用することが目的です。
何でもかんでも非同期処理を行うよりかは、適材適所で使用することを心がけます。
※MonoBehaviourのUpdate()やFixedUpdate()でやったほうが良いこともかなりあります。

今回作るゲームは「シューティングゲーム」です。
とりあえずシューティングゲームの基盤を作るために、必要そうな要素は以下でしょうか。

  • プレイヤー(自機)
  • 敵
  • 弾
  • アイテム

スコア表示等のUIは一旦置いておいて、これらがあれば一応シューティングゲームは出来上がりそうです。
まずは、「プレイヤー」を作るためにはどんな機能が必要かを考えます。

  • 移動機能
  • 発射機能
  • ボム機能

この3つが機能としてあれば、シューティングゲームの「プレイヤー」としての役割は果たせそうです。
とりあえず「移動機能」と「発射機能」があればシューティング自体はできるので、今回はボム機能は捨てましょう。

少し遠回りしましたが、UniTaskを使う箇所はどこにするか悩みました。
移動、発射どちらもUniTaskを使ってもいいですが、移動はInputSystemを使ってMonobehaviour.Update()で監視するほうが処理を複雑にしないので良さそうです。
反対に、発射機能は発射レートを実装し連続発射の管理を行いたい為、非同期処理で「◯フレーム待つ」や「◯秒待つ」といった処理が使えるほうが都合良いです。(移動と同じUpdete()で実装すると移動側に影響が出るというのもある)

と、いうことで移動機能はメインのUpdete()、発射機能はUniTaskで実装していきます。

プレイヤーの移動、射撃を実装

プレイヤー入力受付、操作用コンポーネントを作成します。名前は「PlayerControllerBase」とします。
スクリーンショット 2026-10-24 174910.png
Baseと付けているのは、このクラスを継承してキャラクター専用の動きを付け足したい時のために拡張性を残しています。

移動機能の処理は本筋では無いので、ざっくりと。

PlayerControllerBase.cs

// UnityMethod
protected void Update()
{
    Move();
}

/// <summary>
/// 移動
/// </summary>
protected virtual void Move()
{
    // キー入力から方向を読み取り
    vector2 vec2 = moveAction.action.ReadValue<Vector2>();

    // 移動量計算
    float xMove = vec2.x * moveSpeed;
    flaot yMove = vec2.y * moveSpeed;

    // 移動反映
    transform.Translate(xMove, yMove, transform.position.z)
}

InputSystemを使用して、「Move」というアクションに登録しておいたキーからの入力を読み取り、移動を反映させています。
InputSystemの使い方に関しては本記事では詳しく取り上げませんが、とりあえず上記の方法で実装しました。

次に射撃機能を実装します。

シューティングにおける射撃は、
・ボタンを押している間は弾を撃つ
・離したら止める
で射撃とします。

ということで、PlayerControllerBase.csにボタン入力検知用変数を作り、ボタン入力に対してイベントを登録します。

PlayerControllerBase.cs

/// <summary>
/// ショットボタン
/// </summary>
[SerializeField]
protected InputActionReference shotButtonAction;

protected void Awake()
{
    // ショットボタンイベント登録
    if (shotButtonAction != null)
    {
        shotButtonAction.action.started += OnShot;
        shotButtonAction.action.canceled += ReleaseShot;
        shotButtonAction.action.Enable();
    }
}

「OnShot」イベントが走れば、非同期で射撃処理が走るようになり、
「ReleaseShot」イベントが走れば、非同期で実行中の射撃処理が止まる。
という仕組みにします。

あれ?押しっぱなしで射撃なら、ボタンイベントの登録先は「started」じゃなくて「performed」じゃないの?

と疑問に思った方さすがです。
この実装では、「OnShot」イベントは1回しか走りません。
ただ、今回はこれで大丈夫です。
射撃処理のフローは、以下の形で考えています。

射撃処理フロー.png

ボタン入力がされ、OnShotイベントが発火、OnShotイベント内でShooting()という射撃処理を非同期実行させ、ループを開始、という流れです。
ボタンを離すまでループは終了しないように実装するため、「押している間は射撃」が実現します。

そして、このフローで実装したのがこちらです。

PlayerControllerBase.cs

/// <summary>
/// ショットボタン押下時イベント
/// </summary>
/// <param name="context"></param>
public void OnShot(InputAction.CallbackContext context)
{
    // 連打対策
    CancelBullet();

    // キャンセル監視用トークン作成
    token = new  CancellationTokenSource();

    // 射撃処理実行
    Shooting(token.Token).Forget();
}

/// <summary>
/// 射撃キャンセルイベント
/// </summary>
/// <param name="context"></param>
public void ReleaseShot(InputAction.CallbackContext context)
{
    CancelBullet();
}

/// <summary>
/// 弾発射
/// </summary>
protected virtual async UniTaskVoid Shooting(CancellationToken token)
{
    try
    {
        // 開始前に中断通知があれば例外を投げる
        token.ThrowIfCancellationRequested();
        
        while (true)
        {
            // 一瞬待つ
            await UniTask.Delay(TimeSpan.FromSeconds(bulletInterval), cancellationToken: token);
        
            // 弾生成
            await InstantiateAsync(playerBase.BulletPrefab, GameController.Instance.PlayerBulletSpawnParentTransform, targetTransform.position, Quaternion.identity);
        }
    }
    catch (OperationCanceledException)
    {
        // 正常に止まった時用のハンドリング
    }

/// <summary>
/// 発射キャンセル
/// </summary>
protected virtual void CancelBullet()
{
    if (token != null)
    {
        token.Cancel();
        token.Dispose();
        token = null;
    }
}

それぞれの関数の説明です。

OnShot(InputAction.CallbackContext context)

ボタン登録イベント。
キャンセル監視用トークンを発行し、射撃ループ非同期処理を実行。

ReleaseShot(InputAction.CallbackContext context)

ボタン登録イベント。
ボタンを離したときに実行される。

Shooting(CancellationToken token)

射撃ループ処理。
Delayで待って弾を生成、を繰り返す。
bulletInterval変数は別途設定。
引数で渡されたCancellationTokenでキャンセル通知を監視する。キャンセル通知が発火すると、catchに行きループを抜けられる。
(Delay()の引数に渡すと待っている間にキャンセルされた時にすぐcatchされる)

CancelBullet()

射撃中止処理。
token.Cancel()でキャンセル通知が送れる。
再度射撃する際は再登録されるように、Dispose()を呼びnullを入れる。

非同期処理を開始する時にForget()で実行すると、処理が投げっぱなしになり呼び出し側でawait待ち必要が無くなります。(エラーも出なくなる)
本筋とは別にループ処理させておきたい時に便利ですね。

CancellationTokenSourceクラスで非同期処理を監視することができます。
try-catchを使い、例外処理としてキャンセルされたかを取得します。
そして、ボタンを離した際のイベントでキャンセル通知を送って射撃を終了させます。

これで射撃処理は完成です。

移動射撃.gif

移動しながらの射撃も問題なく行えています。
Update()は移動、非同期は射撃と分けたことで、常に実行される処理同士が影響することなく実装できています。
また、非同期なので一瞬待ってから弾を生成ということが可能になっています。
メリットもりもりですね。

※説明を省略していましたが、弾は勝手にまっすぐ飛ぶようにコンポーネントを作っています。

では、この実装の仕方を軸に敵側の動きも作っていきましょう。

敵の動きと射撃を考える

敵に実装するのは以下の要素です。
・移動
・射撃
・当たり判定(コライダー)

敵はNPCとするので、移動や射撃はプレイヤー入力を待つことはできません。
なので移動と射撃は自動化する必要があります。

そして、移動ってバリエーションが必要ですよね。
シューティングゲームの敵は基本直線移動ですが、【A地点に行って3秒止まってB地点に行く】という動きを実装したい場合、ハードコーディングだとスクリプトが敵毎に増えていき、保守性が無くなってしまいます。
射撃も同様です。

なので、移動と射撃の振る舞いは【コマンド】という形でコンポーネントに登録していくようにします。
敵毎にプレハブを作り、敵プレハブは敵用共通コンポーネント上で振る舞いを変更するという感じです。
また、コマンドはそれぞれ再利用できる形として実装するので、敵Aで作ったコマンドが敵Bでも使用できるようになり、再利用性が高いものになるでしょう!

敵の動きを作る

本来は良くないですが、敵用のコンポーネントは「EnemyBase」という名前で作ります。これに全部入れます。
image.png
皆さんはちゃんと移動処理用コンポーネントと射撃処理用コンポーネントで分けましょう。

それでは処理を....の前に、移動をコマンド化というのはどうするのかを解説します。

すごーく端的に言うと、下記をまとめたクラスを作り、EnemyBaseクラス内で配列として持ちます。
・移動パターン
・次のコマンドまでの待機時間

いやいや、移動パターンってコマンドじゃないのかよ....というツッコミが聞こえてきますが、違います。

今回定義する移動パターンとは、連続したウェイポイントまでの動きをパターンとしています。
想像しやすいように、先に移動パターンをお見せします。
image.png
WaypointListは移動先です。
Durationは秒数で、10秒かけてWaypointListに登録された地点を経由しながら移動、という移動パターンデータになります。
このスクリプタブルオブジェクトに登録された【データ】と、【コマンド実行までの待機時間】で1つのコマンドと定義します。

※今回はDOTweenを使用したウェイポイント移動を実現しています。無料版でもできる範囲ですので、オブジェクト移動自体真似する際は、DOTweeenを導入してください。

EnemyBaseを持った敵は、この移動コマンドリストを上から実行していき、自動移動を実現します。
ガチャガチャハードコーディングするより、インスペクターでパターンデータを作って、配列で実行順番を決めるようになっているので、視覚的にもわかりやすい形になっているかと思います。

では、実装に移ります。
まずはクラスの定義、変数宣言です。

EnemyBase.cs

/// <summary>
/// 移動のコマンド
/// </summary>
[Serializable]
public class MovingCommand
{
    /// <summary>
    /// ムーブコマンド
    /// </summary>
    public MovingData movingData;
    
    /// <summary>
    /// 次のコマンド発火までの時間
    /// </summary>
    public float nextCommandInterval;
}

/// <summary>
/// 移動コマンド
/// </summary>
[SerializeField]
protected List<MovingCommand> movingCommands;
/// <summary>
/// 移動コマンドをループするか
/// </summary>
[SerializeField]
protected bool isMovingCommandLoop;

/// <summary>
/// 移動タスク用トークン
/// </summary>
CancellationTokenSource movingTokenSource;

そして、上記の変数を使用して非同期で移動コマンドを実行する処理を実装します。 非同期である理由は、コマンド間の実行にインターバルを設けたいためです。

EnemyBase.cs

/// <summary>
/// 移動タスク実行
/// </summary>
protected virtual void EnableMove()
{
    movingTokenSource = new CancellationTokenSource();
    
    MovingCommandProc(movingTokenSource.Token).Forget();
}
/// <summary>
/// 移動タスク終了
/// </summary>
protected virtual void DisableMove()
{
    if (movingTokenSource != null)
    {
        movingTokenSource.Cancel();
        movingTokenSource.Dispose();
        movingTokenSource = null;
    }
}

/// <summary>
/// 移動タスク
/// </summary>
protected virtual async UniTaskVoid MovingCommandProc(CancellationToken token)
{
    try
    {
        // コマンドが登録されていないなら、コマンド処理終了
        if (movingCommands == null || movingCommands.Count <= 0)
        {
            DisableMove();
            await UniTask.Yield(token);
        }
        
        // 実行コマンドインデックス
        int commandIndex = 0;
        
        while (true)
        {
            // 登録されているコマンド以上になったらやめる
            if (commandIndex > movingCommands.Count - 1)
            {
                // ループフラグが立っていたら最初から実行
                if (isMovingCommandLoop)
                {
                    commandIndex = 0;
                }
                else
                {
                    break;
                }
            }
            
            // コマンド取得
            var command = movingCommands[commandIndex];
            
            // 移動実行
            await Moving(command.movingData, token);
            
            // 次の移動まで待つ
            if (command.nextCommandInterval > 0.0f)
            {
                await UniTask.Delay(TimeSpan.FromSeconds(command.nextCommandInterval), cancellationToken: token);
            }
            
            commandIndex++;
        }
        
        // 移動完了したので用済み
        Destroy(this.gameObject);
        
        await UniTask.Yield(token);
    }
    catch (OperationCanceledException)
    {
        
    }
}

/// <summary>
/// 移動データを元に移動
/// </summary>
/// <param name="movingData"></param>
/// <param name="token"></param>
protected virtual async UniTask Moving(MovingData movingData, CancellationToken token)
{
    try
    {
        // 移動完了フラグ
        bool isComplete = false;

        // 待機時間があれば待つ
        if (movingData.delay > 0.0f)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(movingData.delay), cancellationToken: token);
        }

        // パスに沿って移動
        transform.DOPath(movingData.waypointList.ToArray(), movingData.duration, pathMode: PathMode.TopDown2D)
                 .SetOptions(closePath:false)
                 .SetEase(movingData.easeType)
                 .onComplete = () => isComplete = true;

        // 移動完了まで待つ
        while (!isComplete)
        {
            await UniTask.Yield(token);
        }
    }
    catch (OperationCanceledException)
    {
    }
}

それぞれ関数の解説です。

EnableMove(), DisableMove()

プレイヤー側で実装したOnShotとReleaseShotと同じ作りです。
EnableMove()は実装側が任意で呼び出すことになりますが、現状オブジェクトが生成された瞬間実行されて問題ないので、Start()で呼んでいます。

また、オブジェクトが破壊(削除)された時にDisableMove()が実行されるようにし、タスクを後片付けするようにしています。

MovingCommandProc()

コマンドの実行を司ります。
登録されたコマンドを実行し切る作りにしており、コマンドをループしないなら無限ループを脱出し、破壊しています。(移動終了地点は画面外にするため)

Moving()

実際に移動をするための関数です。
移動パターンデータが渡され、そのパターンからDOTweenのDOPath()で移動させます。
移動が完了するまでが1コマンドなので、移動完了まで待つようにループ処理を入れています。

ウェイポイント毎をコマンド化も考えたのですが、細かすぎてしまうのと移動地点毎にスクリプタブルオブジェクトを作る事になってしまい、保守も大変なのでウェイポイントをノンストップで動く1連の動きをパターンと決定しています。

これで移動をコマンドとして実装できました。
同じように射撃コマンドも実装しましょう。

敵の射撃を作る

基本的には作りは同じです。
しかし、射撃に関しては「撃ち方」というものが存在するかと思います。

例えば、
・1発だけまっすぐ撃つ
・プレイヤーに向かって1発だけまっすぐ撃つ
・多数の弾を扇型に撃つ
・螺旋の軌道を描くように撃つ
など、弾の撃ち方にも様々あります。
これを共通化を意識して、実装してみます。

まずは、コマンド用クラスから。

EnemyBase.cs

/// <summary>
/// 弾幕のコマンド
/// </summary>
[Serializable]
public class DanmakuCommand
{
    /// <summary>
    /// 弾道ステータスデータ
    /// </summary>
    public BallisticStatusData ballisticStatusData;
    
    /// <summary>
    /// 射出ステータスデータ
    /// </summary>
    public ShotStatusData shotStatusData;
    
    /// <summary>
    /// 次のコマンド発火までの時間
    /// </summary>
    public float nextCommandInterval;
}

/// <summary>
/// 弾幕設定
/// </summary>
[SerializeField]
protected List<DanmakuCommand> danmakuCommands;

/// <summary>
/// コマンドをループするか
/// </summary>
[SerializeField]
protected bool isDanmakuCommandLoop;

射出に必要なデータとして、
・射出ステータス
・弾の弾道ステータス
が必要と考えました。

射出ステータスとは、
どういった射出方法(まっすぐ飛ばすや扇型等)、弾の角度、発射間隔、弾の数など、このコマンドを使用する時にどういった弾の出方をするかを決定します。

弾道ステータスとは、
発射された後の弾の軌道、移動速度を決定しています。
文章では伝わりにくいと思うので、ソースコードになります。

ShotStatusData.cs

/// <summary>
/// 射撃ステータス
/// </summary>
[CreateAssetMenu(fileName = "BulletStatusData", menuName = "STGGame/BulletStatusData")]
public class ShotStatusData : ScriptableObject
{
    /// <summary>
    /// 射出タイプ
    /// </summary>
    public BulletDefine.eFireType fireType;

    /// <summary>
    /// 使用するプレハブ
    /// </summary>
    public GameObject bulletPrefab;

    /// <summary>
    /// 弾の方向
    /// </summary>
    public float angle;

    /// <summary>
    /// 次の弾を発射するまでの間隔
    /// </summary>
    public float interval;

    /// <summary>
    /// 弾の数
    /// </summary>
    public int count;
    
    /// <summary>
    /// リピートする場合、次の弾を発射するまでの間隔
    /// </summary>
    public float repeatInterval;
    
    /// <summary>
    /// リピート回数
    /// </summary>
    public int repeatCount;
}

ちょっとややこしいのが、
intervalとcountが引き金を1回引いたら出てくる弾で、
repeatIntervalとrepeatCountが上記の弾を出す間隔と繰り返し回数
としています。(難しかったらごめんなさい)

BallisticStatusData.cs

/// <summary>
/// 弾道ステータス
/// </summary>
[CreateAssetMenu(fileName = "BallisticStatusData", menuName = "STGGame/BallisticStatusData")]
public class BallisticStatusData : ScriptableObject
{
    /// <summary>
    /// 弾道タイプ
    /// </summary>
    public BulletDefine.eBallisticType ballisticType;
    
    /// <summary>
    /// 弾のスピード
    /// </summary>
    public float bulletSpeed;
}

射出ステータスは銃の種類、弾道ステータスは弾の種類、という感じでしょうか。
個人的にはちょっとニュアンスが違うのですが、伝わりやすい言葉が見つからないです。ごめんなさい。

次に、このステータスをコマンドにして撃つ処理を書きます。

EnemyBase.cs

/// <summary>
/// 弾幕コマンド実行タスク
/// </summary>
/// <param name="onFinish">終了処理</param>
protected virtual async UniTaskVoid DanmakuCommandProc(UnityAction onFinish = null)
{
    try
    {
        int commandIndex = 0;
        var token = danmakuTokenSource.Token;
        
        while (true)
        {
            // カウント以上になったら、ループしていないなら終わる
            if (commandIndex > danmakuCommands.Count - 1)
            {
                // ループするならコマンドを最初からやり直す
                if (isDanmakuCommandLoop)
                {
                    commandIndex = 0;
                }
                else
                {
                    break;   
                }
            }
            
            // コマンド取り出し
            var command = danmakuCommands[commandIndex];
        
            // 弾生成
            await ShootingProc(command.shotStatusData, command.ballisticStatusData, token);
            
            // 一瞬待つ
            await UniTask.Delay(TimeSpan.FromSeconds(command.nextCommandInterval), cancellationToken: token);
            
            commandIndex++;
        }
        
        // 終了処理
        onFinish?.Invoke();
    }
    catch (OperationCanceledException)
    {
        // 終了処理
        onFinish?.Invoke();
    }
}

/// <summary>
/// 弾を撃つ
/// </summary>
/// <param name="shotStatusData">射出ステータスデータ</param>
/// <param name="ballisticStatusData">弾道ステータスデータ</param>
/// <param name="token"></param>
protected virtual async UniTask ShootingProc(ShotStatusData shotStatusData, BallisticStatusData ballisticStatusData, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    
    // リピート回数
    int repeatCount = 0;
    
    // リピート回数分ループ
    while (repeatCount < shotStatusData.repeatCount + 1)
    {
        // 弾を撃つ
        await ShotPatternDecision(shotStatusData, ballisticStatusData, token);
        
        // 次のリピートまで待つ
        await UniTask.Delay(TimeSpan.FromSeconds(shotStatusData.repeatInterval), cancellationToken: token);
        
        repeatCount++;
    }
}

/// <summary>
/// 撃ち方決定
/// </summary>
/// <param name="shotStatusData"></param>
/// <param name="ballisticStatusData"></param>
/// <param name="token"></param>
protected async UniTask ShotPatternDecision(ShotStatusData shotStatusData, BallisticStatusData ballisticStatusData, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    
    switch (shotStatusData.fireType)
    {
        // 単発撃ち
        case BulletDefine.eFireType.Once:
            await OnceShot(shotStatusData, ballisticStatusData, token);
            break;

        // 射出時に自機方向へ飛ばす
        case BulletDefine.eFireType.AimOnce:
            await AimOnceShot(shotStatusData, ballisticStatusData, token);
            break;
        
        default:
            return;
    }
}

/// <summary>
/// 単発打ち
/// </summary>
/// <param name="shotStatusData"></param>
/// <param name="ballisticStatusData"></param>
/// <param name="token"></param>
protected async UniTask OnceShot(ShotStatusData shotStatusData, BallisticStatusData ballisticStatusData, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    
    // 弾の数
    int count = 0;
        
    // 弾の数分ループ
    while (count < shotStatusData.count)
    {
        // 弾生成
        var enemyBullet = Instantiate(shotStatusData.bulletPrefab).GetComponent<EnemyBulletBase>();
        enemyBullet.Setting(ballisticStatusData);
    
        await UniTask.Yield(token);
            
        // 次の弾を出すまで待つ
        await UniTask.Delay(TimeSpan.FromSeconds(shotStatusData.interval),  cancellationToken: token);
            
        count++;
    }
}

DanmakuCommandProc()

前述のMovingCommandProcと同じです。
引数のonFinishは弾幕コマンド終了時になにかイベントを起こしたい場合用に用意しています。

ShootingProc()

shotStatusDataのrepeatCount分ループするための処理です。

ShotPatternDecision()

撃ち方を決定します。
ShotStatusDataで決められた撃ち方から、使用する射出処理をswitchで決定しています。

引数は長いので省略します。
ちょっと射出の機能が複雑化しているので改善する必要はありそうですが、今回はこれでいかせていただきます....!
基本的にはForget()で非同期は投げっぱなし。CancellationTokenSourceで非同期処理の中断監視の形を崩さず実装しています。
非同期処理内でawait待ちする処理を作り、非同期のメリットを活かせるような作りを目指した結果をちゃんと実装できているかと思います。

そして、EnemyBaseクラスを継承したTestEnemyクラスを作り、こんな感じでコマンドを積みます。
image.png

すみませんが、当たり判定やスコア等は別途ご用意ください!

そして基盤としてのシューティングゲームが完成しました。
完成.gif

まとめ

UniTaskによって、非同期処理を簡単に実装することができました。
気に入った点として、コルーチンよりも中断が簡単なことです。
もちろんコルーチンでもできなくはないのですが、コルーチンの外から停止するとコルーチンジョブ自体消し去ってしまうので、考えず中断処理を実装できることが良いですね。

また、UniTaskは比較的軽いジョブなので、メモリ負荷軽減効果がかなり期待できます。
ただ、今回作成したゲームでは軽量化については考えず作ったので、弾の数が多くなるとめちゃくちゃ重くなってしまうので、今後の改善点です。(どうしたものか....)

これでコルーチン教からUniTask教へ改宗できそうです。
長くなりましたが、これで失礼します。


参考資料

来源:Qiita · Unity 开发社区 · qiita.com