Unityを触り始めたころは、イベントシーンのスキップ処理もつい Update() に全部書いてしまいがちですよね。

  • 入力判定
  • ゲージの増減
  • UIの表示・非表示
  • 実際にシーンをスキップする処理

これらを1つの巨大スクリプトに詰め込むと、あとから「ボタンを変えたい」「ゲージ演出を変えたい」「スキップ先を変えたい」といった修正が地獄になります。

そこでこの記事では、「イベントシーン中、ボタンを長押しするとゲージが溜まりスキップする」という機能だけに責務を絞ったコンポーネント 「HoldToSkip」 を作っていきます。
イベントシーン用のコントローラーとは分離し、プレハブにポン付けできる「長押しスキップ専用コンポーネント」にすることで、どのイベントシーンでも再利用しやすい設計を目指します。

【Unity】イベント長押しでスマートにスキップ!「HoldToSkip」コンポーネント

フルコード(HoldToSkip.cs)


using System;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using UnityEngine.InputSystem; // 新Input Systemを使う場合

/// <summary>
/// イベントシーン中に「ボタン長押しでスキップ」を実現するコンポーネント。
/// - 指定時間ボタンを押し続けるとスキップ発火
/// - 進捗をImage.fillAmountで可視化
/// - スキップ時にUnityEventで任意の処理を呼び出し
/// 
/// ※Input Systemを使わない(旧Input.GetButton系)場合のサンプルも
///   クラス末尾のコメントに記載しています。
/// </summary>
public class HoldToSkip : MonoBehaviour
{
    [Header("入力設定")]
    [SerializeField]
    private string actionName = "Submit";
    // ↑ 新Input SystemのAction名(例: "Submit", "Skip" など)
    //   PlayerInputコンポーネントと組み合わせて使う想定です。

    [Header("長押し設定")]
    [SerializeField, Tooltip("この秒数以上押し続けるとスキップ発動")]
    private float holdDuration = 1.5f;

    [SerializeField, Tooltip("ボタンを離したときにゲージをリセットするか")]
    private bool resetOnRelease = true;

    [Header("UI設定")]
    [SerializeField, Tooltip("進捗を表示するImage(Fill Amountを使用)")]
    private Image progressImage;

    [SerializeField, Tooltip("スキップ可能な状態でのみ表示したいCanvasGroup(任意)")]
    private CanvasGroup skipCanvasGroup;

    [SerializeField, Tooltip("スキップUIをフェードインさせる時間")]
    private float uiFadeDuration = 0.25f;

    [Header("イベント")]
    [SerializeField, Tooltip("スキップ完了時に呼び出されるイベント")]
    private UnityEvent onSkipCompleted;

    [SerializeField, Tooltip("スキップ開始(ボタンを押し始めた)時に呼び出されるイベント")]
    private UnityEvent onSkipStarted;

    [SerializeField, Tooltip("スキップキャンセル(ボタンを離した)時に呼び出されるイベント")]
    private UnityEvent onSkipCanceled;

    // 内部状態
    private float currentHoldTime = 0f;
    private bool isHolding = false;
    private bool hasSkipped = false;
    private float uiFadeTimer = 0f;
    private float initialCanvasAlpha = 0f;

    // Input System 用
    private PlayerInput playerInput;
    private InputAction skipAction;

    private void Awake()
    {
        // PlayerInputを同じGameObjectから探す(なければnullのまま)
        playerInput = GetComponent<PlayerInput>();

        if (skipCanvasGroup != null)
        {
            // 初期アルファを覚えておく(インスペクタで設定した値を尊重)
            initialCanvasAlpha = skipCanvasGroup.alpha;
        }

        // 進捗UIが設定されていれば初期化
        if (progressImage != null)
        {
            progressImage.fillAmount = 0f;
        }
    }

    private void OnEnable()
    {
        SetupInputAction();
    }

    private void OnDisable()
    {
        TeardownInputAction();
    }

    /// <summary>
    /// PlayerInput から指定アクションを取得し、コールバック登録
    /// </summary>
    private void SetupInputAction()
    {
        if (playerInput == null)
        {
            return;
        }

        if (string.IsNullOrEmpty(actionName))
        {
            Debug.LogWarning($"[HoldToSkip] actionName が設定されていません。");
            return;
        }

        skipAction = playerInput.actions[actionName];

        if (skipAction == null)
        {
            Debug.LogWarning($"[HoldToSkip] 指定されたアクション '{actionName}' が見つかりません。");
            return;
        }

        skipAction.started += OnSkipActionStarted;
        skipAction.canceled += OnSkipActionCanceled;
    }

    /// <summary>
    /// InputAction のコールバック登録解除
    /// </summary>
    private void TeardownInputAction()
    {
        if (skipAction == null)
        {
            return;
        }

        skipAction.started -= OnSkipActionStarted;
        skipAction.canceled -= OnSkipActionCanceled;
    }

    /// <summary>
    /// ボタンを押し始めたときに呼ばれる
    /// </summary>
    /// <param name="ctx"></param>
    private void OnSkipActionStarted(InputAction.CallbackContext ctx)
    {
        if (hasSkipped)
        {
            // すでにスキップ済みなら無視
            return;
        }

        isHolding = true;
        onSkipStarted?.Invoke();
    }

    /// <summary>
    /// ボタンを離したときに呼ばれる
    /// </summary>
    /// <param name="ctx"></param>
    private void OnSkipActionCanceled(InputAction.CallbackContext ctx)
    {
        if (!isHolding)
        {
            return;
        }

        isHolding = false;

        if (resetOnRelease)
        {
            ResetHold();
        }

        onSkipCanceled?.Invoke();
    }

    private void Update()
    {
        if (hasSkipped)
        {
            // 一度スキップしたらこのコンポーネントの役目は終了
            // (必要であれば、ここでenabled = false; してもよい)
            UpdateUIFade(false);
            return;
        }

        // 長押し時間の更新
        if (isHolding)
        {
            currentHoldTime += Time.deltaTime;

            // 進捗UI更新
            UpdateProgressUI();

            // UIをフェードイン
            UpdateUIFade(true);

            // 規定時間に達したらスキップ
            if (currentHoldTime >= holdDuration)
            {
                CompleteSkip();
            }
        }
        else
        {
            // 押していないときはUIをフェードアウト
            UpdateUIFade(false);
        }
    }

    /// <summary>
    /// スキップ完了処理
    /// </summary>
    private void CompleteSkip()
    {
        if (hasSkipped)
        {
            return;
        }

        hasSkipped = true;
        isHolding = false;

        // 進捗を100%に
        if (progressImage != null)
        {
            progressImage.fillAmount = 1f;
        }

        // 実際のスキップ処理はUnityEvent経由で外から差し込む
        onSkipCompleted?.Invoke();
    }

    /// <summary>
    /// 長押し状態をリセット
    /// </summary>
    private void ResetHold()
    {
        currentHoldTime = 0f;
        UpdateProgressUI();
    }

    /// <summary>
    /// Image.fillAmount を長押し進捗に応じて更新
    /// </summary>
    private void UpdateProgressUI()
    {
        if (progressImage == null)
        {
            return;
        }

        if (holdDuration <= 0f)
        {
            progressImage.fillAmount = 0f;
            return;
        }

        float t = Mathf.Clamp01(currentHoldTime / holdDuration);
        progressImage.fillAmount = t;
    }

    /// <summary>
    /// スキップUIのフェードイン・アウト
    /// </summary>
    /// <param name="visible">trueならフェードイン、falseならフェードアウト</param>
    private void UpdateUIFade(bool visible)
    {
        if (skipCanvasGroup == null || uiFadeDuration <= 0f)
        {
            return;
        }

        // visibleならフェードイン方向、falseならフェードアウト方向へ
        float target = visible ? initialCanvasAlpha : 0f;
        float current = skipCanvasGroup.alpha;

        // Lerpでなめらかに補間
        float lerpT = Time.deltaTime / uiFadeDuration;
        float next = Mathf.Lerp(current, target, lerpT);

        skipCanvasGroup.alpha = next;
        skipCanvasGroup.interactable = visible;
        skipCanvasGroup.blocksRaycasts = visible;
    }
}

/*
============================================================
▼ 旧Input Manager (Input.GetButton) を使いたい場合の簡易版サンプル
   - PlayerInput や InputAction を使わない環境向け
   - 上のクラスとは別に、プロジェクトに追加して使ってください
============================================================

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

public class HoldToSkip_LegacyInput : MonoBehaviour
{
    [Header("入力設定")]
    [SerializeField]
    private string buttonName = "Submit"; // Edit > Project Settings > Input Manager で設定されている名前

    [Header("長押し設定")]
    [SerializeField]
    private float holdDuration = 1.5f;

    [SerializeField]
    private bool resetOnRelease = true;

    [Header("UI設定")]
    [SerializeField]
    private Image progressImage;

    [Header("イベント")]
    [SerializeField]
    private UnityEvent onSkipCompleted;

    private float currentHoldTime = 0f;
    private bool hasSkipped = false;

    private void Update()
    {
        if (hasSkipped)
        {
            return;
        }

        bool isHolding = Input.GetButton(buttonName);

        if (isHolding)
        {
            currentHoldTime += Time.deltaTime;
            UpdateProgressUI();

            if (currentHoldTime >= holdDuration)
            {
                hasSkipped = true;
                onSkipCompleted?.Invoke();
            }
        }
        else if (resetOnRelease)
        {
            currentHoldTime = 0f;
            UpdateProgressUI();
        }
    }

    private void UpdateProgressUI()
    {
        if (progressImage == null || holdDuration <= 0f)
        {
            return;
        }

        float t = Mathf.Clamp01(currentHoldTime / holdDuration);
        progressImage.fillAmount = t;
    }
}

*/

使い方の手順

  1. シーンに「スキップUI」を用意する
    例として、イベントシーン中の右下に「長押しでスキップ」ゲージを表示するケースを考えます。
    • Canvas を作成(すでにある場合はそれを使用)
    • Canvas の子に Image を作成し、Image.Type = FilledFill Method = Radial360 などに設定
    • 初期状態では fillAmount = 0 にしておく(インスペクタでも可)
    • 必要に応じて「長押しでスキップ」などの Text を隣に置く
  2. イベント用の GameObject に「HoldToSkip」コンポーネントを追加
    例:
    • イベントシーンを管理している空の GameObject(例: EventController
    • もしくは UI 専用の GameObject(例: SkipUI

    いずれかを選び、HoldToSkip コンポーネントをアタッチします。

  3. インスペクタで参照をセットする
    • Action Name: 新Input Systemで定義したアクション名(例: SubmitSkip
    • Hold Duration: 何秒押しっぱなしでスキップさせるか(例: 1.5 秒)
    • Progress Image: 手順①で作ったゲージ用 Image をドラッグ&ドロップ
    • Skip Canvas Group: スキップUI全体をまとめた GameObject に CanvasGroup を付けて、それをセット(任意)
    • On Skip Completed: スキップ完了時に呼びたい処理を登録
      • 例1: シーン遷移コンポーネントの LoadNextScene() を呼ぶ
      • 例2: イベントコントローラーの SkipCurrentEvent() を呼ぶ
  4. 具体的な使用例
    • プレイヤー視点のイベントシーン
      プレイヤーが会話イベントを見ている間、右下に「長押しでスキップ」ゲージを表示。
      On Skip Completed に「会話コントローラーの SkipToLastLine()」を登録しておけば、長押しで一気に会話終盤まで飛ばせます。
    • 敵の登場デモシーン
      ボスがカッコよく登場するカットシーンを作ったけれど、周回プレイでは早く戦いたいプレイヤーもいます。
      その場合、ボス登場用の Timeline を再生している GameObject に HoldToSkip を付け、On Skip Completed に「Timeline を強制停止し、ボス戦用の状態に切り替える関数」を登録すればOKです。
    • 動く床の演出シーン
      ステージ開始時に「動く床が順番にせり上がる」演出を Timeline で流している場合、
      HoldToSkip をステージ管理オブジェクトに付けておき、スキップされたら
      • 全ての床を最終位置にスナップさせる
      • プレイヤーの操作を有効化する

      といった処理を On Skip Completed に登録しておくと、演出を見たい人・飛ばしたい人の両方に優しい設計になります。

メリットと応用

HoldToSkip をコンポーネントとして切り出すことで、次のようなメリットがあります。

  • イベントロジックとスキップUIの責務を分離できる
    会話やTimeline制御のスクリプトから「入力処理」「ゲージ制御」「長押し判定」を追い出せるので、イベント側は「スキップされたら何をするか」だけに集中できます。
  • プレハブとして量産しやすい
    「SkipUI」プレハブに HoldToSkip を仕込んでおけば、どのイベントシーンにもドラッグ&ドロップで導入できます。
    イベントごとにスクリプトをコピペして長押し処理を書く必要がなくなります。
  • レベルデザインの自由度が上がる
    「このイベントだけはスキップ禁止にしたい」「このイベントは0.5秒でスキップできるようにしたい」といった調整も、インスペクタの Hold Duration を変えるだけで対応できます。
  • 入力デバイスの違いにも柔軟に対応
    新Input Systemのアクション名を変えるだけで、ゲームパッドのAボタン、キーボードのEnter、マウスボタンなど、好きな入力にマッピングできます。

さらに、ちょっとした改造を加えるだけで、演出の幅も広がります。

改造案:スキップ完了直前に「点滅演出」を入れる

長押し完了の直前にゲージを点滅させて、「もうすぐスキップされますよ」とプレイヤーに視覚的なフィードバックを出す改造例です。


/// <summary>
/// ゲージが一定以上たまったら点滅させる改造例
/// </summary>
private void UpdateProgressUIWithBlink()
{
    if (progressImage == null || holdDuration <= 0f)
    {
        return;
    }

    float t = Mathf.Clamp01(currentHoldTime / holdDuration);
    progressImage.fillAmount = t;

    // 90%以上たまったら点滅させる
    if (t >= 0.9f)
    {
        // Mathf.PingPong を使って 0.3〜1.0 の間でアルファを往復させる
        float blinkAlpha = Mathf.Lerp(0.3f, 1f, Mathf.PingPong(Time.time * 8f, 1f));
        Color c = progressImage.color;
        c.a = blinkAlpha;
        progressImage.color = c;
    }
    else
    {
        // それ以外は常に不透明
        Color c = progressImage.color;
        c.a = 1f;
        progressImage.color = c;
    }
}

この関数を UpdateProgressUI() の代わりに呼ぶように差し替えれば、「もうすぐスキップされる」ことを視覚的に伝えられます。
このように、「長押しスキップ」という単一の責務に絞ったコンポーネント にしておくと、UI演出の改造も簡単に行えますね。