イベントシーンの「スキップ機能」、どう実装していますか?
ありがちなパターンとしては、イベント用のベースシーンを作って、そこに「スキップ用の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を別ノードに切り出しつつ、ロジックはコンポーネントにまとめる…という構成も簡単にできます。
継承ではなくコンポーネントを組み合わせることで、イベントシーンの自由度と保守性を両立していきましょう。
