3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unityの演出スキップ問題をどう解決するか ー ChainRacePattern

3
Last updated at Posted at 2026-03-16

※ 本記事で紹介する ChainRacePattern は、現時点では提案段階の設計パターンです。

背景

Unityでリザルト画面のような比較的長い演出を作る場合、次のような制御が必要になることがあります。

  • 複数のアニメーションを順次・並列に実行する
  • ユーザー操作によって途中で演出をスキップする

一見すると、これらはコルーチンや Tween を組み合わせれば実現できそうに見えます。
しかし実際には、開始方法・終了検出・スキップ時の扱いがそれぞれ異なるため、演出が複雑になるほど統一的に制御しづらくなります。

さらに演出を構成するのはアニメーションだけではありません。
一定時間待つ、任意の関数を実行する、ボタン入力を待つ、といった処理も演出フローの一部です。

そこで ChainRacePattern では、これらをまとめて 開始・完了・スキップという共通操作を持つ Chain として扱います。

前回、ChainRacePattern の概要を紹介する軽めの記事を書きました。
まず雰囲気を掴みたい場合は、こちらから読む方が入りやすいかもしれません。

【Unity】演出スキップがつらすぎたので、ChainRacePattern という仕組みを作った

この記事では、その続きとして、ChainRacePattern の設計と実装の詳細に踏み込みます。

実装例はこちらです。
ChainRacePatternUnity (GitHub)

フラグとコルーチンで管理するとどうなるか

演出スキップを素朴に実装すると、次のようにフラグとコルーチンで全体を制御する形になりがちです。

private bool skipRequested;

private IEnumerator PlayResult()
{
    yield return FadeOut();

    if (skipRequested)
    {
        ApplySkippedState();
        yield break;
    }

    yield return ShowDialog();

    if (skipRequested)
    {
        ApplySkippedState();
        yield break;
    }

    yield return PlayBonus();

    if (skipRequested)
    {
        ApplySkippedState();
        yield break;
    }

    yield return WaitForTap();
    yield return HideDialog();
    yield return FadeIn();
}

この程度ならまだ読めますが、実際にはここに

  • 並列実行
  • スキップ不可区間
  • Animator や Tween の終了処理
  • SE や入力待ちのキャンセル
  • 最終状態への強制遷移

などが加わっていきます。

つまり問題は、スキップ判定そのもの ではなく、
性質の異なる処理を1つの外側の制御コードでまとめて面倒見ようとすると複雑になる ことです。

DOTween や UniTask を使えば、個々の処理はかなり書きやすくなります。
ただ、演出全体を見ると「順次実行」「並列実行」「入力待ち」「途中スキップ」といった異なる性質の処理をまとめて扱う必要があります。少なくとも私が見る限り、これを DOTween や UniTask だけで統一的に整理するのは簡単ではありませんでした。

特に難しいのは、スキップ時に各処理をどう最終状態へ寄せるか です。
待機ならキャンセル、Animator なら最終フレームへ移動、入力待ちならリスナー解除、というように必要な後始末がそれぞれ異なります。

そこで ChainRacePattern では、これらをまとめて扱うための抽象として Chain を導入しています。

演出を Chain として抽象化する

ChainRacePattern では、演出を「アニメーション」ではなく、つながる処理 として扱います。

Chain が持つ基本的な操作は次の3つです。

  • 開始する
  • 完了する
  • スキップする

この抽象化により、アニメーションだけでなく、

  • 一定時間待つ
  • 任意の関数を実行する
  • ボタン入力を待つ

といった処理も同じ枠組みで扱えるようになります。

基底クラスのイメージは次のようなものです。

public abstract class Chain
{
    public UniTask Start()
    {
        // 開始
    }

    public void Skip()
    {
        // スキップ
    }

    protected void Complete()
    {
        // 完了
    }

    protected abstract void StartInternal();
    protected abstract void SkipInternal();
}

ここで重要なのは、Skip() の意味です。
ChainRacePattern では、スキップは単なる中断ではなく、最終状態への遷移 を意味します。

つまり Chain は、開始された後に Skip() された場合、最終状態と整合する状態へ直ちに移行できる必要があります。

Sequence / Parallel / Race

Chain を組み合わせるために、ChainRacePattern では主に次の3種類の合成を用意しています。

ChainSequence

ChainSequence は、複数の Chain を順番に実行します。

await new ChainSequence(
    new ChainDelay(0.5f),
    new ChainAction(() => Debug.Log("Hello")),
    new ChainDelay(1.0f)
).Start();

ChainParallel

ChainParallel は、複数の Chain を同時に開始し、すべてが完了するまで待ちます

await new ChainParallel(
    ChainMoveTween(rectA, targetA, 1.0f),
    ChainMoveTween(rectB, targetB, 1.0f)
).Start();

ChainRace

ChainRace は、複数の Chain を同時に開始し、どれか1つが完了した時点で残りをスキップ します。

await new ChainRace(
    new ChainButton(skipButton),
    new ChainSequence(
        ChainMoveTween(rect, pos1, 1.0f),
        ChainMoveTween(rect, pos2, 1.0f)
    )
).Start();

この Race が、演出スキップを表現するうえで重要になります。

演出スキップを Race で表現する

ChainRacePattern の核はここです。

従来の実装では、スキップボタンが押されたときに、

  • 今動いている Tween を止める
  • 画面の最終状態を手動で作る
  • 終了待ち中の処理をキャンセルする
  • 次の演出へ進める

といった個別制御を書きがちです。

ChainRacePattern では、ユーザー入力も Chain の一種 とみなします。
するとスキップは、次のように書けます。

new ChainRace(
    new ChainButton(skipButton),
    new ChainSequence(
        演出A,
        演出B,
        演出C
    )
)

この形にすると、

  • 演出が最後まで終わればそのまま完了
  • ボタンが先に押されれば演出側を自動でスキップ

となります。

スキップ処理を「外側から割り込ませるイベント」としてではなく、演出フローの構成要素そのもの として扱えるのがポイントです。

リザルト画面のコード例

実際のリザルト画面に近い構成は、次のように書けます。

new ChainSequence(
    // 1. フェードアウト + ダイアログ表示(スキップ可能)
    new ChainRace(
        new ChainButton(screenButton),
        new ChainParallel(
            fadePanel.ChainFade(false),
            resultDialog.ChainShowDialog()
        )
    ),
    // 2. ボーナス演出(スキップ可能)
    new ChainRace(
        new ChainButton(screenButton),
        resultDialog.ChainShowBonus()
    ),
    // 3. タッチ待ち
    ChainTouchScreen(),
    // 4. ダイアログ非表示 + フェードイン
    new ChainParallel(
        resultDialog.ChainHideDialog(),
        fadePanel.ChainFade(true)
    )
)

このコードの良いところは、演出フローがそのままコードの構造になっている ことです。

  • 順番に進む部分は Sequence
  • 同時にやる部分は Parallel
  • どちらかが先ならよい部分は Race

と、要件がそのままクラス名に対応しています。

Chain 実装時のルール

独自の Chain を実装する場合、基本的に次のルールを満たす必要があります。

  1. StartInternal() に開始時の手続きを実装する
  2. 手続きが完了したら Complete() を呼び出す
  3. SkipInternal() は開始後に呼び出される場合がある。呼び出された場合は、直ちに最終状態へ遷移しなければならない
  4. SkipInternal() の内部から Complete() は呼び出さない
  5. isFastForward は、開始直後に Skip() が実行されることが確定している場合に true になる

この中で最も厳しい条件は 3 です。

SkipInternal() が呼ばれたときに「直ちに最終状態へ遷移しなければならない」という制約は、見た目以上に重要です。
ここを曖昧に実装すると、スキップしたはずなのに内部状態が途中のまま残ったり、後続処理と不整合を起こしたりします。

SkipInternal(), Complete(), isFastForward の役割

SkipInternal() は「止める」ではなく「最後まで進んだ状態にする」

たとえば ChainDelay は通常時は UniTask.Delay() を待ち、完了時に Complete() を呼びます。
一方で SkipInternal()CancellationTokenSource を cancel するだけです。delay の終状態は「待機が終わっていること」なので、これで十分です。

protected override void StartInternal()
{
    if (isFastForward)
    {
        Complete();
        return;
    }

    cts = new CancellationTokenSource();
    DelayAsync(cts.Token).Forget();
}

protected override void SkipInternal()
{
    if (cts != null && !cts.IsCancellationRequested)
    {
        cts.Cancel();
    }
}

一方で ChainAnimator はもう少し厳しいです。
Unity の Animator.Play() は、呼び出した直後に状態が即座に反映されるとは限りません。そこで ChainAnimator では、通常開始時にも、fast-forward 時にも、skip 時にも Animator.Update(0f) を呼んで、状態をその場で確定させています。

protected override void StartInternal()
{
    if (animator == null)
    {
        Complete();
        return;
    }

    if (isFastForward)
    {
        animator.Play(stateName, layer, 1f);
        animator.Update(0f);
        Complete();
        return;
    }

    animator.speed = 1.0f;
    animator.Play(stateName, layer, 0f);
    animator.Update(0.0f);

    cts = new CancellationTokenSource();
    WaitForAnimationAsync(cts.Token).Forget();
}

protected override void SkipInternal()
{
    cts?.Cancel();

    if (animator != null)
    {
        animator.Play(stateName, layer, 1f);
        animator.Update(0f);
    }
}

SkipInternal() の責務は、単に処理を中断することではなく、その場で最後まで進んだのと同じ状態にすること です。

Complete() は最後に呼ぶ

Chain の内部では、処理完了時に Complete() を呼び出します。
ただし Complete() は単なる完了フラグではありません。これが呼ばれると、その場で次の Chain が進行し始める可能性があります。

たとえば ChainSequence では、子 Chain の完了コールバックの中で次の Chain を開始しています。

private void NextChain()
{
    if (chainList.Count <= 0)
    {
        isEnabled = false;
        Complete();
        return;
    }

    currentChain = chainList[0];
    chainList.RemoveAt(0);
    currentChain.SetIsFastForward(isFastForward);
    currentChain.SetCompleteCallback(() =>
    {
        currentChain = null;
        NextChain();
    });
    currentChain.Start();
}

そのため、Complete() は極力 その関数の最後 に呼ぶべきです。
Complete() の後に処理を書くと、すでに次の Chain が動き始めているのに、前の Chain の後処理がまだ続いている、という再入的な状況が起こりえます。

感覚としては、Complete()return に近いものだと考えた方が安全です。

isFastForward は未実行 Chain の消費を支える

isFastForward は、「開始直後に Skip() が呼ばれることが確定している」場合に true になります。

これは特に Sequence / Parallel / Race のスキップ処理で重要です。
たとえば ChainSequence.SkipInternal() では、

  1. 現在実行中の ChainSkip()
  2. まだ未実行の ChainisFastForward = trueStart()
  3. 即完了しなかったものだけ Skip()

という手順で、残りの Chain を最後まで「消費」します。

protected override void SkipInternal()
{
    currentChain?.Skip();
    currentChain = null;

    while (chainList.Count > 0)
    {
        Chain c = chainList[0];
        chainList.RemoveAt(0);

        bool complete = false;
        c.SetCompleteCallback(() => complete = true);
        c.SetIsFastForward(true);
        c.Start();

        if (!complete)
        {
            c.Skip();
        }
    }

    isEnabled = false;
}

この発想のおかげで、「未開始だから無視」ではなく、未開始の処理も必要に応じて終状態へ寄せながら消費する ことができます。

isFastForward の実用的な使い道として、SE 再生の抑制 があります。
たとえば SE を含む複数の演出をまとめてスキップする場合、未実行の演出を開始してしまうと、スキップした瞬間に SE だけがまとめて鳴ってしまうことがあります。

そこで SE 再生側の Chain が開始時に isFastForward を見て、true のときは鳴らさないようにすれば、スキップ時の不自然な轟音を防げます。
このように isFastForward は、不要な初期化の省略だけでなく、スキップ時の副作用抑制 にも使えます。

最小の独自 Chain 実装例

たとえば「関数を1つ実行して終わる」だけの Chain はかなりシンプルに書けます。

public class ChainAction : Chain
{
    Action actionToCall;

    public ChainAction(Action action)
    {
        actionToCall = action;
    }

    protected override void StartInternal()
    {
        actionToCall?.Invoke();
        actionToCall = null;
        Complete();
    }

    protected override void SkipInternal()
    {
        actionToCall = null;
    }
}

何もしないで即完了する ChainNop はさらに単純です。

public class ChainNop : Chain
{
    protected override void StartInternal()
    {
        Complete();
    }

    protected override void SkipInternal()
    {
    }
}

このぐらい小さい実装から始められます。

実装時の注意点

1. SkipInternal() は最後まで進んだ状態にする

スキップは「止める」ではなく、本来最後まで進んだときの状態に揃える ことです。
特に Animator のように状態反映が1フレーム遅れることがある仕組みでは、Update(0f) のような補助が必要になる場合があります。

2. Complete() の後ろに処理を書かない

Complete() はその場で後続処理を解放します。
Chain が直ちに次へ進むことを前提に、Complete() は関数の最後に置く方が安全です。

3. 各 Chain が自分の終了責務を持つ

スキップ時の後始末を外側から一括管理するのではなく、各 ChainSkipInternal() の中で自分の終了処理を持つ方が責務分離しやすくなります。

4. Race の粒度を設計すると演出の操作感が変わる

  • 全体を1つの Race で包めば「まとめてスキップ」
  • 各セクションごとに Race を置けば「セクション単位スキップ」
  • Race を置かない区間を作れば「そこだけスキップ不可」

という設計ができます。
この粒度を決めるだけでも、演出の操作感はかなり変わります。

この方法の課題

もちろん、この方法ですべてが簡単になるわけではありません。
Chain を組み合わせて演出を構成できるようになる一方で、抽象化によって別の難しさも生まれます。

まず、SequenceParallelRace の組み合わせが深くなると、実行経路の追跡が難しくなる ことがあります。
コード上の構造は整理されますが、実際にどの Chain が今動いていて、どの Race がどのタイミングで勝ったのか、といった実行状態は、ネストが深くなるほど追いかけにくくなります。

また、ログを出して追跡しようとしても、スタックやレースの入れ子が深くなることで、実行状態の把握がしづらくなる 場面があります。
特にスキップが絡むと、通常完了したのか、Race に負けて消費されたのか、isFastForward で短絡されたのかが見えにくくなります。

さらに、組み合わせた Chain のどこかに不具合がある場合、原因箇所の特定が難しい ことがあります。
これはこの手法に限らず、抽象化された非同期処理全般にある問題ですが、Chain の組み合わせが増えるほど「どの Chain の開始・完了・スキップが意図とずれたのか」を切り分ける必要が出てきます。

加えて、現在の実装ではエラー処理を最小限にしているため、そのまま大規模プロジェクトへ導入するには追加の設計が必要 です。
たとえば例外処理、デバッグ用の可視化、実行中 Chain のトレース、エラー時の伝播方針などは、用途に応じて拡張した方がよいと思います。

つまりこの方法は、フラグ地獄を避けやすくする一方で、構造化された非同期制御としての別の難しさ を持ち込みます。
そのため、導入する場合は「演出フローを明確に書けること」と「デバッグや可視化の支援をどう用意するか」をセットで考えるのが重要です。

まとめ

Unity の演出制御では、順次実行・並列実行・スキップ対応が必要になることが多く、コルーチンや Tween だけでは統一的に扱いにくい場面があります。

ChainRacePattern では、演出を Chain として抽象化し、Sequence / Parallel / Race で組み立てることで、これらを同じ枠組みで扱えるようにしています。

特に重要なのは次の点です。

  • スキップは中断ではなく最終状態への遷移として扱う
  • SkipInternal() は最終状態に遷移させる
  • Complete() は後続処理を解放するため最後に呼ぶ
  • Race によりスキップ入力も演出フローの一部として表現できる
  • isFastForward により未実行 Chain の消費も安全に扱える

演出制御でフラグや個別分岐が増えてきた場合、開始・完了・スキップを持つ処理として抽象化する というアプローチは有効だと思います。

おわりに

この記事では、演出スキップ問題に対する一つのアプローチとして ChainRacePattern を紹介しました。
ただ正直なところ、他のチームがこの問題をどう解決しているのか、私も気になっています。
もし「うちはこうやってる」という事例や知見があれば、ぜひコメントで共有していただけると嬉しいです。

実装全体や README、サンプルコードは以下のリポジトリに置いてあります。
ChainRacePatternUnity (GitHub)

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?