Godot 4で「スクショ撮りたいな〜」と思ったとき、ついシーンのどこかに Input.is_action_just_pressed("screenshot") を書き足して、その場しのぎで実装してしまいがちですよね。
プレイヤーにも、UIにも、デバッグ用のオーバーレイにもコピペしていくと、気づけば「どのノードがスクショを担当してるのか分からないツギハギ状態」になりがちです。

さらに、

  • 日時付きのファイル名で保存したい
  • 保存先フォルダを変えたい
  • キーをF12だけじゃなくてゲームパッドにも対応したい

といった要望が出てくると、あちこちのスクリプトを修正する羽目になります。
こういう「どのシーンにも共通で欲しい機能」は、継承や巨大な「GameManager」ノードに押し込むより、独立したコンポーネントとしてポン付けできる形にしておくと管理がめちゃくちゃ楽になります。

そこで今回は、どのシーンにもアタッチして使えるスクリーンショット用コンポーネント、「ScreenshotTaker」 を用意しました。
シーンツリーの好きな場所に1個置いておくだけで、F12(など任意の入力)で画面キャプチャが撮れるようになります。

【Godot 4】ワンコンポーネントでスクショ管理!「ScreenshotTaker」コンポーネント

このコンポーネントは、

  • 指定した入力アクション(デフォルト: F12)でスクショ撮影
  • 日時付きファイル名でPNG保存
  • 保存フォルダ名をエディタから変更可能
  • スクショ撮影時にコンソールログやシグナルで通知

といった機能をひとまとめにしたものです。
どのノードにも依存しない 純粋なコンポーネント なので、2D/3D問わずどのプロジェクトにもそのまま持ち込めます。


フルコード: ScreenshotTaker.gd


extends Node
class_name ScreenshotTaker
## スクリーンショット撮影コンポーネント
##
## - 任意の入力アクションでスクショを撮影
## - 日時付きファイル名で PNG 保存
## - 保存先ディレクトリやファイル名フォーマットをエクスポートで調整可能
## - シグナルで撮影完了を通知

## 撮影完了シグナル
## @param path 保存先のフルパス (res:// or user://)
signal screenshot_saved(path: String)
signal screenshot_failed(error: String)

## スクショ用の入力アクション名
## 例: "ui_screenshot" にして、InputMap で F12 を割り当てる
@export var action_name: String = "ui_screenshot"

## 保存先ディレクトリ (user:// は書き込み可能なユーザーデータ領域)
## 末尾にスラッシュがあってもなくてもOK。自動で整形します。
@export var save_directory: String = "user://screenshots"

## ファイル名フォーマット (拡張子は自動で .png を付与)
## 以下のプレースホルダが利用できます:
## - {year}  : 4桁の年 (例: 2025)
## - {month} : 2桁の月 (例: 03)
## - {day}   : 2桁の日 (例: 09)
## - {hour}  : 2桁の時 (24h表記)
## - {minute}: 2桁の分
## - {second}: 2桁の秒
## - {ms}    : 3桁のミリ秒
## 例: "screenshot_{year}{month}{day}_{hour}{minute}{second}"
@export var filename_format: String = "screenshot_{year}{month}{day}_{hour}{minute}{second}"

## PNG 以外にしたい場合の拡張子("png", "jpg" 等)
## Godot 4 の Image.save_* 系に対応した形式を指定してください。
@export var file_extension: String = "png"

## 撮影時に標準出力へログを出すかどうか
@export var print_log: bool = true

## ゲームがポーズ中でも入力を受け付けるか
## true にすると、pause_mode = PROCESS のノードとして動作させるのと同じ効果になります。
@export var process_in_pause: bool = true


func _ready() -> void:
    # ポーズ中も動作させたい場合は、自分の pause_mode を調整
    if process_in_pause:
        pause_mode = Node.PAUSE_MODE_PROCESS

    # 保存ディレクトリの整形と作成
    save_directory = _normalize_directory_path(save_directory)
    _ensure_directory_exists(save_directory)

    if print_log:
        print("[ScreenshotTaker] Initialized. Action: %s, Dir: %s" % [action_name, save_directory])


func _input(event: InputEvent) -> void:
    # ポーズ中でも動かしたい場合は、自前で判定する
    if get_tree().paused and not process_in_pause:
        return

    # 指定アクションが just pressed されたらスクショ撮影
    if event.is_action_pressed(action_name):
        _take_screenshot()


func _take_screenshot() -> void:
    # メインビューポートを取得
    var viewport: Viewport = get_viewport()
    if viewport == null:
        var err := "[ScreenshotTaker] Viewport not found."
        push_warning(err)
        emit_signal("screenshot_failed", err)
        return

    # 現在のフレームを Image として取得
    # get_texture().get_image() はレンダリング後のフレームを取得できます。
    var texture := viewport.get_texture()
    if texture == null:
        var err := "[ScreenshotTaker] Viewport texture not available."
        push_warning(err)
        emit_signal("screenshot_failed", err)
        return

    var image: Image = texture.get_image()
    if image == null:
        var err := "[ScreenshotTaker] Failed to get image from viewport."
        push_warning(err)
        emit_signal("screenshot_failed", err)
        return

    # ファイルパスを生成
    var filepath := _build_file_path()

    # 形式に応じて保存
    var error := _save_image(image, filepath)
    if error != OK:
        var err_str := "[ScreenshotTaker] Failed to save screenshot: %s (Error %d)" % [filepath, error]
        push_warning(err_str)
        emit_signal("screenshot_failed", err_str)
        return

    if print_log:
        print("[ScreenshotTaker] Screenshot saved: %s" % filepath)

    emit_signal("screenshot_saved", filepath)


func _build_file_path() -> String:
    # 現在時刻を取得
    var dt := Time.get_datetime_dict_from_system() # {year, month, day, hour, minute, second}
    var ms := Time.get_ticks_msec() % 1000

    # プレースホルダを埋める
    var filename := filename_format
    filename = filename.replace("{year}",  "%04d" % dt.year)
    filename = filename.replace("{month}", "%02d" % dt.month)
    filename = filename.replace("{day}",   "%02d" % dt.day)
    filename = filename.replace("{hour}",  "%02d" % dt.hour)
    filename = filename.replace("{minute}","%02d" % dt.minute)
    filename = filename.replace("{second}","%02d" % dt.second)
    filename = filename.replace("{ms}",    "%03d" % ms)

    # 拡張子を整形
    var ext := file_extension.strip_edges().to_lower()
    if ext.begins_with("."):
        ext = ext.substr(1, ext.length() - 1)
    if ext == "":
        ext = "png"

    # ディレクトリ + ファイル名 + 拡張子 でフルパスを構成
    return "%s%s.%s" % [save_directory, filename, ext]


func _normalize_directory_path(dir: String) -> String:
    # 空ならデフォルトを採用
    if dir == "":
        dir = "user://screenshots"

    var normalized := dir.strip_edges()

    # 末尾にスラッシュを付ける
    if not normalized.ends_with("/"):
        normalized += "/"

    return normalized


func _ensure_directory_exists(dir: String) -> void:
    # user:// などの仮想パスを OS パスに変換
    var fs := DirAccess.open(dir)
    if fs == null:
        # ディレクトリがない場合は作成を試みる
        var err := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(dir))
        if err != OK:
            push_warning("[ScreenshotTaker] Failed to create directory: %s (Error %d)" % [dir, err])
        else:
            if print_log:
                print("[ScreenshotTaker] Created directory: %s" % dir)


func _save_image(image: Image, path: String) -> int:
    var ext := file_extension.strip_edges().to_lower()
    if ext.begins_with("."):
        ext = ext.substr(1, ext.length() - 1)

    match ext:
        "png":
            return image.save_png(path)
        "jpg", "jpeg":
            return image.save_jpg(path)
        "webp":
            return image.save_webp(path)
        "tga":
            return image.save_tga(path)
        "bmp":
            return image.save_bmp(path)
        _:
            # 未対応形式は PNG として保存を試みる
            push_warning("[ScreenshotTaker] Unsupported extension '%s', falling back to PNG." % ext)
            return image.save_png(path)


## --- 便利API: コードから直接スクショを撮りたいとき用 ---

## 外部から明示的にスクリーンショットを撮影するための関数。
## 例: get_node("ScreenshotTaker").capture_now()
func capture_now() -> void:
    _take_screenshot()

使い方の手順

このコンポーネントは どのシーンにも1個置くだけ でOKです。プレイヤーに継承させたり、UIにベタ書きしたりする必要はありません。

手順①: スクリプトをプロジェクトに追加

  1. res://components/ など好きな場所に ScreenshotTaker.gd を作成し、上記コードをコピペ。
  2. 保存すると、class_name ScreenshotTaker により、エディタの「ノードを追加」ダイアログから直接 ScreenshotTaker が選べるようになります。

手順②: InputMap にアクションを追加(F12割り当て)

  1. メニューから Project > Project Settings… を開く。
  2. Input Map タブを選択。
  3. 新しいアクション名として ui_screenshot を追加。
  4. ui_screenshot に F12 キーを割り当てる。

デフォルトの action_name"ui_screenshot" なので、そのまま使う場合は設定はこれだけです。

手順③: シーンに ScreenshotTaker をアタッチ

例えば、2Dゲームでメインシーンがこんな感じだとします:

Main (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    └── CollisionShape2D
 ├── UI (CanvasLayer)
 │    └── Control
 └── ScreenshotTaker (ScreenshotTaker)
  1. メインシーン(上記の Main など)を開く。
  2. シーンツリーでルートノードを選択し、「+」ボタンからノードを追加。
  3. 検索欄で ScreenshotTaker を入力し、追加。
  4. インスペクタで必要なら以下を調整:
    • action_name: 使いたい InputMap のアクション名(例: "ui_screenshot"
    • save_directory: user://screenshots など
    • filename_format: 例 "shot_{year}-{month}-{day}_{hour}-{minute}-{second}"
    • file_extension: "png" のままでOK(必要なら "jpg" など)

これでゲーム実行中に F12 を押すと、user://screenshots/ 以下に日時付きファイル名でスクリーンショットが保存されます。

手順④: 他のシーンでも再利用する

コンポーネント指向の良いところは、どのシーンでも同じノードを1個置くだけで同じ機能が使えることです。

例えば、

TitleScreen (Control)
 ├── Background (TextureRect)
 ├── Buttons (VBoxContainer)
 └── ScreenshotTaker (ScreenshotTaker)

とか、

EnemyTestScene (Node3D)
 ├── Enemy (CharacterBody3D)
 ├── Camera3D
 └── ScreenshotTaker (ScreenshotTaker)

のように、2D/3D問わず好きなシーンに ScreenshotTaker を1つ足すだけで、同じスクショ撮影フローを共有できます。
プレイヤー用シーンや敵用シーンにスクショロジックを混ぜないのがポイントですね。


メリットと応用

この ScreenshotTaker コンポーネントを使うと、

  • シーン構造がスッキリ:
    • 「スクショ撮影」はゲームプレイロジックとは関係ないので、プレイヤーやUIのスクリプトから分離できます。
    • 「スクショは ScreenshotTaker がやってる」と一目で分かるので、デバッグが楽になります。
  • 再利用性が高い:
    • 別プロジェクトにも ScreenshotTaker.gd をコピペすれば、そのまま使えます。
    • InputMap と保存先だけ変えれば、社内ツールやレベルエディタ用プロジェクトにも流用可能です。
  • 継承に縛られない:
    • 「スクショ対応プレイヤー」「スクショ対応UI」といった派生クラスを作る必要がありません。
    • どのノード階層にもぶら下げられる、完全に独立したコンポーネントとして扱えます。
  • テストやツール連携がしやすい:
    • capture_now() をテストコードやデバッグUIから呼び出せば、自動撮影も簡単です。
    • screenshot_saved シグナルを拾って、直後に画像をビューワーで開く、といったツール連携も可能です。

改造案: 撮影後に最新スクショへのパスを返す関数

例えば、「最後に撮ったスクショのパスをすぐに参照したい」ケース向けに、こんな小さな拡張もありですね。


var _last_screenshot_path: String = ""

func _take_screenshot() -> void:
    var viewport := get_viewport()
    if viewport == null:
        var err := "[ScreenshotTaker] Viewport not found."
        push_warning(err)
        emit_signal("screenshot_failed", err)
        return

    var texture := viewport.get_texture()
    if texture == null:
        var err := "[ScreenshotTaker] Viewport texture not available."
        push_warning(err)
        emit_signal("screenshot_failed", err)
        return

    var image: Image = texture.get_image()
    if image == null:
        var err := "[ScreenshotTaker] Failed to get image from viewport."
        push_warning(err)
        emit_signal("screenshot_failed", err)
        return

    var filepath := _build_file_path()
    var error := _save_image(image, filepath)
    if error != OK:
        var err_str := "[ScreenshotTaker] Failed to save screenshot: %s (Error %d)" % [filepath, error]
        push_warning(err_str)
        emit_signal("screenshot_failed", err_str)
        return

    _last_screenshot_path = filepath

    if print_log:
        print("[ScreenshotTaker] Screenshot saved: %s" % filepath)

    emit_signal("screenshot_saved", filepath)


func get_last_screenshot_path() -> String:
    ## 直近で保存したスクリーンショットのパスを返す
    return _last_screenshot_path

これで、例えばデバッグ用の UI から get_last_screenshot_path() を呼んで、「最後に撮ったスクショを OS のファイルエクスプローラで開く」なんて機能も簡単に追加できます。

スクショ撮影のような「どのゲームにもありがちな横断的な機能」こそ、継承ではなくコンポーネントとして切り出しておくと、プロジェクト全体がかなり見通し良くなります。ぜひ、自分のプロジェクトの「なんとなく毎回書いてる処理」を見つけて、同じノリでコンポーネント化してみてください。