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

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

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

正文 · AI 翻译

大家好。

你在做异步处理吗?

我出于宗教上的理由,只用协程来做异步处理。

这次为了脱离那个宗教,我学习了使用 UniTask 来实现比协程更简单、也更易用的异步处理,这里是我的备忘。
希望这能对即将开始使用 UniTask 的人有所帮助。


制作環境

・Unity 6000.6.0f1
・JetBrains Rider 2026.1.4
・UniTask

项目准备

这次为了制作纵向射击游戏,将在2D项目中创建。
UniTask 本身无论在2D还是3D中,要做的事情完全一样,所以没有特别的影响。

Unity 启动完成后,首先安装 UniTask。
关于安装方法,有很多比我写的要易懂得多,请参考那些资料。

参考

想要用 UniTask 对什么进行异步处理

单纯地说“用异步处理吧!”是很简单的,但这次我们的目的是在制作游戏的过程中使用 UniTask。
与其不管什么都用异步处理,不如注意在合适的地方使用合适的方法。
※也有相当多的事情放在 MonoBehaviour 的 Update() 或 FixedUpdate() 中做更好。

这次制作的游戏是“射击游戏”。
总之,为了制作射击游戏的基础,看起来需要的要素大概如下吧。

  • 玩家(自机)
  • 敵
  • 弾
  • 道具

分数显示等 UI 暂时搁置,有了这些,射击游戏大致上就能做出来了。
首先,思考为了制作“玩家”需要什么样的功能。

  • 移動機能
  • 発射機能
  • 炸弹功能

只要具备这三个功能,似乎就能发挥射击游戏中“玩家”的作用了。
暂且只要有“移动功能”和“发射功能”,射击游戏本身就能成立,所以这次先舍弃炸弹功能吧。

虽然绕了点弯路,但我为该把使用 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」事件只会触发一次。
不过,这次这样就可以了。
射击处理的流程,我们考虑按以下形式进行。

射撃処理フロー.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() 执行的话,处理就会变成放任不管(fire-and-forget),调用方不再需要 await 等待。(也不会再抛出错误)
在想让循环处理与主线分开进行时很方便呢。

可以用 CancellationTokenSource 类来监视异步处理。
使用 try-catch,通过异常处理来获取是否被取消。
然后在松开按钮时的事件中发送取消通知,结束射击。

这样射击处理就完成了。

移動射撃.gif

边移动边射击也能顺利进行。
通过把 Update() 负责移动、异步负责射击分开,可以让这些常驻执行的处理互不影响地实现。
另外,因为是异步,所以可以做到先等待一瞬间再生成子弹。
优点多多呢。

※前面省略了说明,其实子弹是通过组件做成会自动直线飞行的。

那么,就以这种实现方式为核心,来制作敌人一侧的动作吧。

思考敌人的移动与射击

为敌人实现的是以下要素。
・移动
・射击
・碰撞判定(碰撞体)

由于敌人是NPC,所以移动和射击不能等待玩家输入。
因此移动和射击需要自动化。

而且,移动是需要各种变化的吧。
射击游戏的敌人基本是直线移动,但如果想实现【前往A地点,停留3秒,再前往B地点】这样的动作,硬编码的话脚本就会随着敌人数量不断增加,失去可维护性。
射击也是同样的道理。

因此,移动和射击的行为将以【命令】的形式注册到组件中。
为每个敌人制作预制体,敌人预制体在敌人通用组件上更改行为,大概就是这种感觉。
另外,命令会以实现为各自可复用的形式,所以在敌人A上制作的命令也能用于敌人B,从而具备很高的复用性!

制作敌人的移动

本来这样做不太好,但敌人用的组件会以“EnemyBase”这个名字来创建。把所有内容都放进这里面。
image.png
大家要好好地分成移动处理用组件和射击处理用组件。

那么,在处理……之前,我先解说一下将移动命令化该怎么做。

非常直截了当地说,就是创建一个汇总以下内容的类,并在EnemyBase类内以数组形式持有。
・移动模式
・到下一个命令为止的等待时间

不不,移动模式不就是命令吗……我仿佛听到了这样的吐槽,但并不是。

这次定义的移动模式,是把到连续路点为止的移动作为模式。
为了便于想象,我先展示一下移动模式。
image.png
WaypointList是移动目的地。
Duration是秒数,表示用10秒一边经过WaypointList中登记的地点一边移动,这样的移动模式数据。
将登记在这个ScriptableObject中的【数据】和【到执行命令为止的等待时间】定义为一个命令。

※这次使用DOTween实现了路点移动。这也是免费版可以做到的范围,所以如果要模仿对象移动本身,请导入DOTweeen。

持有 EnemyBase 的敌人会从上往下依次执行这个移动命令列表,从而实现自动移动。
相比杂乱地硬编码,这里是在 Inspector 中创建模式数据,并用数组决定执行顺序,所以我想在视觉上也更容易理解。

那么,进入实现阶段。
首先是类的定义和变量声明。

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()进行移动。
由于直到移动完成为止才算一个命令,因此加入了等待移动完成的循环处理。

我也曾考虑过将每个路点都做成命令,但那样会过于细碎,而且会变成每个移动地点都要创建一个ScriptableObject,维护也很麻烦,所以决定将路点不停顿地连续移动的一整套动作作为一个模式。

这样就实现了作为命令的移动。
接下来同样地实现射击命令吧。

制作敌人的射击

基本上构造是相同的。
不过,关于射击,应该存在所谓“射击方式”的东西。

例如,
・只笔直地发射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是扣动一次扳机后射出的子弹,
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() 让异步发起后就不管(fire-and-forget)。用 CancellationTokenSource 在不破坏异步处理中断监视这一形式的前提下进行了实现。
在异步处理内创建 await 等待的处理,我认为已经好好地实现了以打造能发挥异步优势的结构为目标所得到的结果。

然后,创建一个继承 EnemyBase 类的 TestEnemy 类,并像这样添加命令。
image.png

不好意思,碰撞判定和分数等请另行准备!

于是,作为基础的射击游戏完成了。
完成.gif

总结

通过 UniTask,可以轻松实现异步处理。
我喜欢的一点是,它比协程更容易中断。
当然,用协程也不是做不到,但如果从协程外部停止,协程任务本身就会被消除,所以能够不用多想就实现中断处理,这一点很好。

此外,UniTask 是相对轻量的任务,因此可以相当期待其减轻内存负载的效果。
不过,这次制作的游戏并没有考虑轻量化,所以子弹数量一多就会变得非常卡,这是今后的改进点。(该怎么办呢……)

这样看来,似乎可以从协程教改信 UniTask 教了。
虽然篇幅变长了,但就此失礼了。


参考資料

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