Godotでボタン入力を処理するとき、つい「プレイヤーのスクリプト」に直接こう書いてしまいがちですよね。

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("attack"):
        # 攻撃開始
    if event.is_action_released("attack"):
        # 長押しかどうかを自前で判定して…

このやり方、最初はシンプルに見えますが、

  • プレイヤーごとに「長押し判定ロジック」をコピペしがち
  • UIボタンでも同じ処理を書きたくなって、またコピペ
  • 「短押し・長押し・ダブルタップ…」と条件が増えると、_unhandled_input が地獄化

と、だんだんスクリプトが肥大化していきます。
Godotのシーン継承で「LongPressButton」みたいな派生シーンを作る手もありますが、それはそれで

  • 「このボタンは継承版?オリジナル版?」と管理がややこしい
  • プレイヤー・敵・UIなど、種類ごとに継承パターンが増殖する

と、継承のツリーが深くなりがちです。

そこで今回は、「押された時間を測って、短押しか長押しかを判定するだけ」のロジックを、
どのノードにもポン付けできるコンポーネントとして切り出してみましょう。

その名も TapHoldCheck コンポーネントです。


【Godot 4】短押し・長押しをキレイに分離!「TapHoldCheck」コンポーネント

TapHoldCheck は、

  • 指定した Input アクション(例: "attack")を監視
  • 押していた時間を計測
  • しきい値(例: 0.35秒)より短ければ「短押し」、長ければ「長押し」と判定
  • それぞれに対応するシグナルを発火

という、シンプルな「入力判定コンポーネント」です。

プレイヤー、敵AI、UIボタン、動く床のスイッチなど、
「とりあえず長押し判定が欲しい」ノードにアタッチして、シグナルをつなげるだけで使えます。


フルコード:TapHoldCheck.gd

extends Node
class_name TapHoldCheck
## 任意のノードにアタッチして使える「長押し判定」コンポーネント。
## 指定した Input アクションを監視し、短押し・長押しで別シグナルを発火します。
##
## 特徴:
## - プレイヤー、敵、UIボタンなど、どこでも再利用可能
## - 継承不要、コンポーネントとしてアタッチするだけ
## - 入力の監視方法を「グローバル入力」か「フォーカス中のUI」に切り替え可能

# ---------------------------------------------------------
# 設定パラメータ
# ---------------------------------------------------------

@export_group("Input Settings")
@export var action_name: StringName = "ui_accept":
    ## 監視する Input アクション名。
    ## Project Settings > Input Map で定義されている名前を指定します。
    set(value):
        action_name = value

@export_range(0.05, 5.0, 0.01, "or_greater")
var hold_threshold_seconds: float = 0.35:
    ## 何秒以上押されていたら「長押し」とみなすか。
    ## これより短ければ「短押し」とみなします。
    set(value):
        hold_threshold_seconds = max(0.0, value)

@export var consume_input: bool = false:
    ## true の場合、このコンポーネントが処理した入力を「消費」します。
    ## 他の _unhandled_input などに同じ入力を渡したくないときに有効にします。
    var _dummy := false setget , _get_dummy
    func _get_dummy(): return _dummy

@export_enum("Global(_unhandled_input)", "UI(_gui_input)")
var input_mode: int = 0:
    ## どの入力ルートで監視するか。
    ## - Global: _unhandled_input で監視(プレイヤー操作など)
    ## - UI: このノードが Control の子にある場合、その Control の _gui_input から呼び出す想定
    ##   (UIモードでは、自前で forward_event() を呼ぶ必要があります)
    pass

@export_group("Behavior")
@export var enable_while_paused: bool = false:
    ## 一時停止中(SceneTree.paused = true)でも判定を続けるかどうか。
    ## true の場合、process_mode を PAUSABLE から ALWAYS に切り替えます。
    set(value):
        enable_while_paused = value
        _update_process_mode()

@export var auto_disable_on_tree_exit: bool = true:
    ## ノードがツリーから抜けたときに、自動的に状態をリセットするかどうか。
    pass


# ---------------------------------------------------------
# シグナル
# ---------------------------------------------------------

## 短押しが確定したときに発火されます。
## 引数: pressed_time - 実際に押されていた時間(秒)
signal tap_short(action_name: StringName, pressed_time: float)

## 長押しが確定したときに発火されます。
## 引数: pressed_time - 実際に押されていた時間(秒)
signal tap_hold(action_name: StringName, pressed_time: float)

## 押し始めた瞬間に発火されます。
signal tap_started(action_name: StringName)

## 長押し状態に「移行した瞬間」に発火されます。
## 例: 0.35秒がしきい値なら、0.35秒に到達したフレームで一度だけ発火。
signal hold_entered(action_name: StringName)


# ---------------------------------------------------------
# 内部状態
# ---------------------------------------------------------

var _is_pressed: bool = false
var _pressed_time: float = 0.0
var _has_become_hold: bool = false


# ---------------------------------------------------------
# ライフサイクル
# ---------------------------------------------------------

func _ready() -> void:
    _update_process_mode()

func _update_process_mode() -> void:
    process_mode = Node.PROCESS_MODE_ALWAYS if enable_while_paused else Node.PROCESS_MODE_PAUSABLE

func _exit_tree() -> void:
    if auto_disable_on_tree_exit:
        _reset_state()


# ---------------------------------------------------------
# 入力の受け口
# ---------------------------------------------------------

## Global モード用: シーンツリー全体の未処理入力を監視します。
func _unhandled_input(event: InputEvent) -> void:
    if input_mode != 0:
        return
    if _handle_input_event(event) and consume_input:
        get_viewport().set_input_as_handled()

## UI モード用: Control ノードからイベントを渡すためのヘルパー。
## 例:
## func _gui_input(event):
##     $TapHoldCheck.forward_event(event)
func forward_event(event: InputEvent) -> void:
    if input_mode != 1:
        return
    _handle_input_event(event)


# ---------------------------------------------------------
# メインロジック
# ---------------------------------------------------------

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

    _pressed_time += delta

    # まだ「長押し状態」になっておらず、しきい値を超えたら hold_entered を一度だけ発火
    if not _has_become_hold and _pressed_time >= hold_threshold_seconds:
        _has_become_hold = true
        emit_signal("hold_entered", action_name)


## 実際の入力イベント処理。
## 押された/離されたタイミングで内部状態を更新し、必要なシグナルを発火します。
func _handle_input_event(event: InputEvent) -> bool:
    var handled := false

    # 押された瞬間を検出
    if event.is_action_pressed(action_name):
        _on_action_pressed()
        handled = true

    # 離された瞬間を検出
    if event.is_action_released(action_name):
        _on_action_released()
        handled = true

    return handled


func _on_action_pressed() -> void:
    _is_pressed = true
    _pressed_time = 0.0
    _has_become_hold = false
    emit_signal("tap_started", action_name)


func _on_action_released() -> void:
    if not _is_pressed:
        return

    _is_pressed = false

    # 押されていた時間に応じて、短押しか長押しかを判定
    var was_hold := _pressed_time >= hold_threshold_seconds

    if was_hold:
        emit_signal("tap_hold", action_name, _pressed_time)
    else:
        emit_signal("tap_short", action_name, _pressed_time)

    # 状態リセット
    _pressed_time = 0.0
    _has_become_hold = false


func _reset_state() -> void:
    _is_pressed = false
    _pressed_time = 0.0
    _has_become_hold = false

使い方の手順

ここでは代表的な 2 パターンを例にします。

  • ① プレイヤーの攻撃ボタン(短押しで弱攻撃、長押しで溜め攻撃)
  • ② UIボタン(短押しで決定、長押しで詳細表示)

手順①:Input Map の設定

まずは、監視したいアクションを Input Map に登録します。

  1. メニューから Project > Project Settings... を開く
  2. Input Map タブを選択
  3. attack という名前のアクションを追加
  4. キーボードの J キーやゲームパッドのボタンなどを割り当てる

UI用なら ui_accept など既存のアクションを使ってもOKです。

手順②:プレイヤーにコンポーネントをアタッチ

プレイヤーシーンの例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TapHoldCheck (Node)
  1. Player シーンを開く
  2. Player ノードの子として Node を追加し、スクリプトに TapHoldCheck.gd をアタッチ
  3. インスペクタで以下のように設定
    • action_name: "attack"
    • hold_threshold_seconds: 0.35 〜 0.5 あたり(好みで)
    • input_mode: Global(_unhandled_input)
    • consume_input: 他で同じアクションを使わないなら true でもOK

次に、Player 側のスクリプトでシグナルを受け取ります。

# Player.gd (例)
extends CharacterBody2D

@onready var tap_hold_check: TapHoldCheck = $TapHoldCheck

func _ready() -> void:
    # 短押し・長押しのシグナルを接続
    tap_hold_check.tap_short.connect(_on_attack_tap_short)
    tap_hold_check.tap_hold.connect(_on_attack_tap_hold)

# 短押し: 通常攻撃
func _on_attack_tap_short(action_name: StringName, pressed_time: float) -> void:
    # 短押し時間で威力を少し変える、みたいな拡張も可能
    print("短押し攻撃!時間: %s 秒" % pressed_time)
    _do_normal_attack()

# 長押し: 溜め攻撃
func _on_attack_tap_hold(action_name: StringName, pressed_time: float) -> void:
    print("長押し攻撃!時間: %s 秒" % pressed_time)
    _do_charged_attack()


func _do_normal_attack() -> void:
    # ここに通常攻撃の処理を書く
    pass


func _do_charged_attack() -> void:
    # ここに溜め攻撃の処理を書く
    pass

Player 側は「攻撃の中身」に集中できていて、
「長押し判定ロジック」は完全に TapHoldCheck に追い出されていますね。

手順③:UIボタンでの利用例

次は、UI の Button で短押しと長押しを分けたいケースです。

MenuRoot (Control)
 ├── VBoxContainer
 │    └── StartButton (Button)
 │         └── TapHoldCheck (Node)
 └── ...
  1. StartButton の子に Node を追加し、TapHoldCheck.gd をアタッチ
  2. TapHoldCheck の設定
    • action_name: "ui_accept"(または UI 用に定義したアクション)
    • input_mode: UI(_gui_input)

UI モードでは、Button 側から _gui_input を経由してイベントを渡します。

# StartButton.gd
extends Button

@onready var tap_hold_check: TapHoldCheck = $TapHoldCheck

func _ready() -> void:
    tap_hold_check.tap_short.connect(_on_tap_short)
    tap_hold_check.tap_hold.connect(_on_tap_hold)

func _gui_input(event: InputEvent) -> void:
    # Button が受け取った UI イベントを TapHoldCheck にそのまま渡す
    tap_hold_check.forward_event(event)

func _on_tap_short(action_name: StringName, pressed_time: float) -> void:
    print("UIボタン短押し: ゲーム開始")
    _start_game()

func _on_tap_hold(action_name: StringName, pressed_time: float) -> void:
    print("UIボタン長押し: 詳細メニューを開く")
    _open_detail_menu()


func _start_game() -> void:
    # シーン切り替えなど
    pass

func _open_detail_menu() -> void:
    # 詳細設定ウィンドウを開くなど
    pass

Button 自体は普通の Button のままで、
「長押しをどう扱うか」だけを TapHoldCheck に任せる形になっています。

手順④:動く床スイッチなど、他の例

例えば、長押ししている間だけ動く床を動かしたい場合:

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TapHoldCheck (Node)
# MovingPlatform.gd
extends Node2D

@onready var tap_hold_check: TapHoldCheck = $TapHoldCheck
var _is_moving: bool = false

func _ready() -> void:
    tap_hold_check.tap_started.connect(_on_tap_started)
    tap_hold_check.tap_hold.connect(_on_tap_hold)
    tap_hold_check.tap_short.connect(_on_tap_short)

func _process(delta: float) -> void:
    if _is_moving:
        position.x += 100.0 * delta  # 適当な移動処理

func _on_tap_started(action_name: StringName) -> void:
    # 押し始めたらとりあえず動かす
    _is_moving = true

func _on_tap_hold(action_name: StringName, pressed_time: float) -> void:
    # 長押し完了後も動かし続けたいなら何もしない、など
    pass

func _on_tap_short(action_name: StringName, pressed_time: float) -> void:
    # 短押しだったらすぐ止める
    _is_moving = false

「動き方」は MovingPlatform が担当し、「どのボタンをどう解釈するか」は TapHoldCheck が担当、という
きれいな責務分離になっています。


メリットと応用

TapHoldCheck コンポーネントを使うメリットを整理すると:

  • ノード構造がスッキリ
    「LongPressPlayer」「LongPressButton」みたいな派生シーンを量産しなくて済みます。
    どのノードにも TapHoldCheck を 1 個ぶら下げるだけ。
  • ロジックの再利用性が高い
    プレイヤー、敵、UI、ギミック…すべて同じコンポーネントを使い回し可能。
    しきい値やアクション名は @export で個別に調整できます。
  • テストしやすい
    入力の判定ロジックが 1 箇所にまとまっているので、
    「長押し判定がバグってる?」と思ったら TapHoldCheck だけ見ればOK。
  • 合成(Composition)志向の設計
    「プレイヤーは CharacterBody2D で、+長押し判定、+HP管理、+ステートマシン…」
    と、機能ごとにコンポーネントを足していく設計にしやすくなります。

応用としては、

  • hold_entered を使って「長押し中だけチャージエフェクトを出す」
  • pressed_time を使って「押していた時間に応じて威力やゲージ量を変える」
  • 複数の TapHoldCheck を同じノードに付けて、「攻撃ボタンは短押し/長押し」「ジャンプボタンは長押しでホバリング」など複合入力

など、かなり遊べます。

改造案:押している間だけコールバックする「ホールド中コールバック」

例えば、「押している間ずっと何かしたい」ケース(チャージゲージの増加など)のために、
「ホールド中に毎フレーム呼ばれるコールバック」を足してみる案です。

# TapHoldCheck.gd のどこかに追加

## 長押し状態の間、毎フレーム呼び出されるコールバックを登録する例。
## 使用側:
##   tap_hold_check.set_hold_update_callback(func(dt, t): _update_charge(dt, t))
var _hold_update_callback: Callable = Callable()

func set_hold_update_callback(callback: Callable) -> void:
    _hold_update_callback = callback


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

    _pressed_time += delta

    if not _has_become_hold and _pressed_time >= hold_threshold_seconds:
        _has_become_hold = true
        emit_signal("hold_entered", action_name)

    # 長押し状態なら、毎フレームコールバックを呼ぶ
    if _has_become_hold and _hold_update_callback.is_valid():
        _hold_update_callback.call(delta, _pressed_time)

これで、使用側はこんな感じで「チャージゲージを溜める」処理を分離できます。

# Player.gd の _ready などで
tap_hold_check.set_hold_update_callback(
    func(delta: float, pressed_time: float) -> void:
        _charge_gauge = clampf(_charge_gauge + delta * 0.5, 0.0, 1.0)
)

このように、TapHoldCheck 自体も「小さな責務」に保ちながら、
必要に応じてコールバックやシグナルを足していくと、
継承地獄に陥らずに、どんどん柔軟な入力システムを構築できます。

ぜひ、自分のプロジェクトでも「長押し判定」をコンポーネント化してみてください。