イベントシーンの「スキップ機能」、どう実装していますか?
ありがちなパターンとしては、イベント用のベースシーンを作って、そこに「スキップ用のUI」「入力処理」「タイマー処理」などを全部詰め込んで、各イベントシーンはそれを継承…みたいな構成がありますよね。

でもこのやり方、

  • イベントシーンごとに微妙に仕様が違うと、ベースシーンがどんどん肥大化する
  • 「このイベントだけスキップ禁止」みたいな例外処理が増えてカオスになる
  • スキップUIの見た目を変えたいだけなのに、イベントシーン全部を修正する羽目になる

…と、なかなかつらい構造になりがちです。
そこで今回は、イベントシーンのルートや任意のノードに「ポン付け」するだけで、長押しでスキップできるゲージ付きコンポーネントを作ってみましょう。

コンポーネント名は HoldToSkip(長押しスキップ)
イベントシーン側は「スキップされたときに何をするか」だけを実装し、入力処理とゲージ表示は全部コンポーネントに丸投げする構成にします。


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

以下が、HoldToSkip.gd のフルコードです。
UI(ゲージ)まで含めて 1 コンポーネントで完結するようにしています。


extends CanvasLayer
class_name HoldToSkip
"""
イベントシーンなどにアタッチして使う「長押しスキップ」コンポーネント。

・指定したアクションを長押しするとゲージが溜まり、一定時間でスキップ確定
・ゲージの見た目は ProgressBar ベース(シーン内に自動生成)
・スキップ確定時に `skip_requested` シグナルを発火
・外側(イベント側)はこのシグナルを受け取って、シーン終了や次のイベントへ進める
"""

signal skip_started              # 長押し開始時
signal skip_canceled             # 長押しが途中で離された時
signal skip_completed            # ゲージが満タンになった瞬間
signal skip_requested            # 実際に「スキップして!」と要求するシグナル

@export_group("Input")
@export var action_name: StringName = "ui_accept" :
	set(value):
		action_name = value
		# デバッグしやすいようにログを出しておく
		if Engine.is_editor_hint():
			print("[HoldToSkip] action_name set to: ", action_name)

@export_range(0.1, 5.0, 0.1, "or_greater") var hold_time: float = 1.5
## 何秒長押ししたらスキップ確定とみなすか

@export_group("UI")
@export var show_ui: bool = true
## ゲージUIを表示するかどうか(falseなら完全に非表示で動作だけする)

@export var auto_create_progress_bar: bool = true
## true: 内部で ProgressBar を自動生成
## false: 自前で ProgressBar を用意し、`progress_bar_path` に指定する

@export var progress_bar_path: NodePath
## 既存の ProgressBar を使いたい場合に指定する

@export var anchor_bottom_margin: float = 32.0
## 画面下からどれくらいの位置にゲージを表示するか(自動生成時用)

@export_group("Behavior")
@export var enable_during_pause: bool = false
## ポーズ中もスキップ入力を受け付けるか

@export var auto_hide_on_complete: bool = true
## スキップ確定後にUIを自動で非表示にするか

@export var consume_input: bool = true
## true の場合、スキップ用の入力を他のシステムに渡さない(Input.set_mouse_mode などは対象外)

var _is_holding: bool = false
var _hold_elapsed: float = 0.0
var _progress_bar: ProgressBar

func _ready() -> void:
	# ポーズ中の挙動
	process_mode = Node.PROCESS_MODE_ALWAYS if enable_during_pause else Node.PROCESS_MODE_INHERIT

	if show_ui:
		_setup_progress_bar()
	else:
		if _progress_bar:
			_progress_bar.visible = false

	_reset_state()


func _setup_progress_bar() -> void:
	if not auto_create_progress_bar and progress_bar_path != NodePath():
		# 既存の ProgressBar を使うモード
		_progress_bar = get_node_or_null(progress_bar_path)
		if _progress_bar:
			_configure_progress_bar(_progress_bar)
		else:
			push_warning("[HoldToSkip] progress_bar_path が指定されていますが、ノードが見つかりません。")
		return

	# 自動生成モード
	_progress_bar = ProgressBar.new()
	_progress_bar.name = "HoldToSkipProgress"
	_configure_progress_bar(_progress_bar)
	add_child(_progress_bar)

	# 画面下部にアンカー固定
	_progress_bar.anchor_left = 0.3
	_progress_bar.anchor_right = 0.7
	_progress_bar.anchor_top = 1.0
	_progress_bar.anchor_bottom = 1.0
	_progress_bar.offset_left = 0
	_progress_bar.offset_right = 0
	_progress_bar.offset_bottom = -anchor_bottom_margin
	_progress_bar.offset_top = _progress_bar.offset_bottom - 24.0


func _configure_progress_bar(bar: ProgressBar) -> void:
	bar.min_value = 0.0
	bar.max_value = 1.0
	bar.value = 0.0
	bar.visible = show_ui
	bar.rounded = true
	bar.show_percentage = false
	bar.mouse_filter = Control.MOUSE_FILTER_IGNORE


func _reset_state() -> void:
	_is_holding = false
	_hold_elapsed = 0.0
	if _progress_bar:
		_progress_bar.value = 0.0


func _process(delta: float) -> void:
	if not is_inside_tree():
		return

	# 入力の状態を毎フレーム確認
	var pressed := Input.is_action_pressed(action_name)

	if pressed:
		if not _is_holding:
			# 長押し開始
			_is_holding = true
			_hold_elapsed = 0.0
			emit_signal("skip_started")
		else:
			# 長押し継続中
			_hold_elapsed += delta
	else:
		if _is_holding:
			# 途中で離された
			_is_holding = false
			_hold_elapsed = 0.0
			if _progress_bar:
				_progress_bar.value = 0.0
			emit_signal("skip_canceled")
		return

	# ここまで来ているということは押され続けている
	if _is_holding:
		var ratio := clamp(_hold_elapsed / max(hold_time, 0.001), 0.0, 1.0)
		if _progress_bar:
			_progress_bar.value = ratio

		if _hold_elapsed >= hold_time:
			# スキップ確定
			_is_holding = false
			emit_signal("skip_completed")
			emit_signal("skip_requested")
			if auto_hide_on_complete and _progress_bar:
				_progress_bar.visible = false


func _unhandled_input(event: InputEvent) -> void:
	if not consume_input:
		return
	if event.is_action(action_name):
		# 同じアクションを他に伝播させたくない場合に消費
		get_viewport().set_input_as_handled()


# --- 公開API -------------------------------------------------------------

func reset_and_show() -> void:
	"""
	外部から呼び出して、ゲージをリセット&再表示したいとき用。
	別イベントで再利用する場合などに。
	"""
	_reset_state()
	if _progress_bar:
		_progress_bar.visible = show_ui


func set_enabled(enabled: bool) -> void:
	"""
	スキップ機能そのものを一時的に無効化したいときに使う。
	例: 重要な会話シーンの最中だけスキップ禁止にする、など。
	"""
	set_process(enabled)
	set_process_unhandled_input(enabled)
	if not enabled:
		_reset_state()

使い方の手順

ここでは、典型的な「イベントシーン(会話シーン)」と、敵のイントロ演出などでの使い方を例に説明します。

手順①:インプットマップにアクションを用意する

プロジェクト設定 > Input Map で、スキップ用のアクションを追加します。

  • アクション名の例: event_skip
  • キー: Space / Gamepad Button A など

コンポーネント側の action_name"event_skip" に設定すればOKです。

手順②:イベントシーンにコンポーネントを追加する

イベントシーンの構成例:

Cutscene01 (Node2D)
 ├── DialogController (Node)      # 会話やイベント進行を管理するスクリプト
 ├── CharactersRoot (Node2D)      # キャラクターたち
 └── HoldToSkip (CanvasLayer)     # ★今回のコンポーネント

HoldToSkip.gd をプロジェクトに保存したら、
シーンツリーで 「+」ボタン → CanvasLayer を追加 → スクリプトに HoldToSkip を指定 するだけです。

  • action_name : "event_skip"
  • hold_time : 1.5(秒)など好みで調整
  • show_ui : true(ゲージを表示)
  • auto_create_progress_bar : true(まずはこれでOK)

手順③:スキップされたときの処理をつなぐ

イベントシーン側では、「スキップされたらどうするか」だけを実装します。
例として、DialogController.gd で、会話を最後まで飛ばしてシーンを閉じる処理を書いてみましょう。


extends Node

@onready var hold_to_skip: HoldToSkip = $"../HoldToSkip"

func _ready() -> void:
	# コンポーネントのシグナルに接続
	hold_to_skip.skip_requested.connect(_on_skip_requested)


func _on_skip_requested() -> void:
	# ここに「スキップ時の挙動」を書く
	# 例: 会話を最後まで進めてシーンを閉じる
	print("イベントがスキップされました。")

	# ここはプロジェクトに合わせて書き換えてください
	# 例: カスタムの EventManager に「スキップ完了」を伝えるなど
	get_tree().change_scene_to_file("res://scenes/next_stage.tscn")

こうしておくと、イベントシーンごとに

  • 「スキップされたらタイトルに戻る」
  • 「スキップされたらステージ開始に飛ぶ」
  • 「スキップされたら次のイベントへ」

といった挙動を、継承なし・共通ベースシーンなしで、コンポーネント+シグナルだけで切り替えられます

手順④:別の用途(敵イントロ・動く床の演出など)にも再利用する

たとえばボス戦前のイントロ演出をスキップしたい場合:

BossIntro (Node2D)
 ├── AnimationPlayer
 ├── Boss (CharacterBody2D)
 └── HoldToSkip (CanvasLayer)

extends Node2D

@onready var anim_player: AnimationPlayer = $AnimationPlayer
@onready var hold_to_skip: HoldToSkip = $HoldToSkip

func _ready() -> void:
	hold_to_skip.skip_requested.connect(_on_skip_requested)
	anim_player.play("intro")

func _on_skip_requested() -> void:
	# イントロアニメを飛ばして、ボス戦の本番状態に強制移行
	anim_player.stop()
	_start_battle()

func _start_battle() -> void:
	# 戦闘開始のセットアップ処理
	print("ボス戦開始!")

同じ HoldToSkip コンポーネントを、イベントシーンでもボスイントロでも、さらには長いチュートリアルや動く床の演出などにもそのまま使い回せます。


メリットと応用

この HoldToSkip コンポーネントを使うメリットを整理してみます。

  • イベントごとの「スキップ処理」を分離できる
    各イベントシーンは「スキップされたら何をするか」だけを実装し、
    入力やゲージ処理はコンポーネントが全部担当します。
  • シーン構造がフラットで見通しがよくなる
    「EventBase」みたいな巨大な親シーンを継承する必要がなく、
    ルートノードに HoldToSkip を 1 個ぶら下げるだけでOKです。
  • UIの見た目だけ差し替えやすい
    自前の ProgressBar をシーン上に置いて progress_bar_path を指定すれば、
    スタイルを好きにいじれます。ロゴ付き、テキスト付きなども簡単ですね。
  • 「長押しスキップ」の仕様を一箇所で管理できる
    何秒でスキップするか、どのアクションを使うかなどを、
    コンポーネントのエクスポート変数だけで調整できます。

継承ベースで「イベント用ベースシーン」を作ってしまうと、
そのベースシーンにロジックとUIがどんどん溜まっていき、「ちょっと変えたい」がどんどん怖くなります。
コンポーネントとして分離しておけば、イベント側は「スキップされたときの反応」だけに集中できるので、責務の分離がきれいに保てますね。

改造案:スキップ開始時に画面にメッセージを出す

例えば、「長押しを開始したときだけチュートリアル風のメッセージを出す」改造をしてみましょう。
HoldToSkip に以下の関数を追加し、シグナルに接続して使います。


func connect_to_label(label: Label) -> void:
	"""
	長押し開始・キャンセル・完了に応じて Label のテキストを変える簡易ユーティリティ。
	UI をコンポーネント外に分離したいときのサンプル。
	"""
	skip_started.connect(func():
		label.text = "長押しでスキップ中..."
		label.visible = true
	)

	skip_canceled.connect(func():
		label.text = ""
		label.visible = false
	)

	skip_completed.connect(func():
		label.text = "スキップしました"
		await get_tree().create_timer(1.0).timeout
		label.text = ""
		label.visible = false
	)

イベントシーン側では、例えば:


@onready var hold_to_skip: HoldToSkip = $HoldToSkip
@onready var skip_label: Label = $UI/SkipLabel

func _ready() -> void:
	hold_to_skip.connect_to_label(skip_label)
	hold_to_skip.skip_requested.connect(_on_skip_requested)

こんな感じで、UIを別ノードに切り出しつつ、ロジックはコンポーネントにまとめる…という構成も簡単にできます。
継承ではなくコンポーネントを組み合わせることで、イベントシーンの自由度と保守性を両立していきましょう。