Godot 4 でデバッグしていると、print() のログがエディタのコンソールにしか出ず、「ビルドした実機」や「Web エクスポート」だと確認が面倒になることが多いですよね。特にスマホ実機での挙動確認や、ステージ固有のバグ調査をしているときに、ゲーム画面の中でログが見られたらなぁ…と思うことは多いはずです。

また、よくある「デバッグ用 UI シーン」をプレイヤーや敵シーンに継承でベタッとくっつけてしまうと、

  • ログ表示のためだけに UI ノードを継承してしまい、シーン階層が肥大化する
  • 別のシーンでも同じ仕組みを使いたくなったときに、継承関係の整理が面倒
  • 最終的に「ログ付き Player」「ログ付き Enemy」など、似たようなシーンが乱立しがち

こういうときこそ「継承より合成(Composition)」の出番です。
ログ表示の機能を 1 つの独立したコンポーネントに切り出して、必要なシーンにポンとアタッチするだけにしておきましょう。

この記事では、画面内に半透明の黒窓を出し、print() の内容をゲーム内テキストで表示するためのコンポーネント 「LogConsole」 を実装していきます。

【Godot 4】ゲーム内で print を丸見えに!「LogConsole」コンポーネント

この LogConsole は、

  • シーンツリーに 1 個置くだけで、ゲーム画面上に「簡易デバッグコンソール」を表示
  • print() / push_error() などのログをフックしてテキスト表示
  • 半透明の黒背景 + スクロール可能なログビュー
  • キー操作で表示/非表示を切り替え可能

という、「開発中にとても便利だけど本番では簡単にオフれる」タイプのコンポーネントです。


フルコード:LogConsole.gd


extends CanvasLayer
class_name LogConsole
## 画面内に半透明の黒窓を出し、print() の内容をゲーム内テキストで表示するコンポーネント。
##
## 【使い方の概要】
## 1. 任意のシーンに LogConsole を追加(CanvasLayer として配置)
## 2. オプションでサイズ・表示行数・トグルキーなどを設定
## 3. ゲーム中に print() するだけで、ログがコンソールウィンドウに流れる
## 4. 指定キーで表示 / 非表示を切り替え可能

@export_category("Appearance")
## コンソールの幅(画面ピクセル単位)
@export var console_width: int = 600
## コンソールの高さ(画面ピクセル単位)
@export var console_height: int = 250
## 画面左上からの X オフセット
@export var margin_left: int = 20
## 画面左上からの Y オフセット
@export var margin_top: int = 20
## 背景の色(デフォルトは半透明の黒)
@export var background_color: Color = Color(0, 0, 0, 0.6)
## テキストカラー
@export var font_color: Color = Color(1, 1, 1, 1)
## フォントサイズ
@export var font_size: int = 14

@export_category("Behavior")
## 最大保持行数。これを超えると古いログから削除される
@export var max_lines: int = 200
## 初期状態でコンソールを表示するかどうか
@export var start_visible: bool = true
## キー入力で表示/非表示を切り替えるか
@export var enable_toggle_key: bool = true
## 表示/非表示を切り替えるキー(InputMap のアクション名)
@export var toggle_action: StringName = &"ui_debug_console"
## print() をフックするかどうか(false にすると標準の print のみ)
@export var hook_print: bool = true
## Godot のエラー/警告出力も拾うかどうか
@export var hook_errors_and_warnings: bool = true

@export_category("Filter")
## この文字列を含むログだけを表示(空文字なら全て表示)
@export var include_filter: String = ""
## この文字列を含むログは表示しない(優先度は include_filter より低い)
@export var exclude_filter: String = ""

# 内部ノード
var _panel: Panel
var _scroll: ScrollContainer
var _label: RichTextLabel

# ログの生配列
var _lines: PackedStringArray = []

# 元の print 関数を退避しておくための変数
var _original_print: Callable
var _original_printerr: Callable
var _original_push_error: Callable
var _original_push_warning: Callable

func _ready() -> void:
    # UI をコードから構築するので、シーン側で子ノードを用意する必要はない
    _build_ui()

    visible = start_visible

    # InputMap にトグル用アクションがなければ自動で追加(F1 に割り当て)
    if enable_toggle_key and not InputMap.has_action(toggle_action):
        InputMap.add_action(toggle_action)
        var ev := InputEventKey.new()
        ev.keycode = Key.F1
        InputMap.action_add_event(toggle_action, ev)

    # print 系をフック
    if hook_print:
        _hook_print_functions()

    if hook_errors_and_warnings:
        _hook_error_functions()

    # 最初のメッセージ
    _add_line("[LogConsole] Initialized. Press %s to toggle." % [str(toggle_action)])


func _exit_tree() -> void:
    # シーンから外れるときに print フックを元に戻す
    _restore_print_functions()
    _restore_error_functions()


func _process(_delta: float) -> void:
    if enable_toggle_key and Input.is_action_just_pressed(toggle_action):
        visible = not visible


# =========================
# UI 構築
# =========================
func _build_ui() -> void:
    # Panel(背景)
    _panel = Panel.new()
    _panel.name = "LogConsolePanel"
    add_child(_panel)

    _panel.anchor_left = 0.0
    _panel.anchor_top = 0.0
    _panel.anchor_right = 0.0
    _panel.anchor_bottom = 0.0
    _panel.offset_left = margin_left
    _panel.offset_top = margin_top
    _panel.offset_right = margin_left + console_width
    _panel.offset_bottom = margin_top + console_height

    # 背景色を設定
    var style := StyleBoxFlat.new()
    style.bg_color = background_color
    _panel.add_theme_stylebox_override("panel", style)

    # ScrollContainer
    _scroll = ScrollContainer.new()
    _scroll.name = "Scroll"
    _scroll.anchor_left = 0.0
    _scroll.anchor_top = 0.0
    _scroll.anchor_right = 1.0
    _scroll.anchor_bottom = 1.0
    _scroll.offset_left = 6
    _scroll.offset_top = 6
    _scroll.offset_right = -6
    _scroll.offset_bottom = -6
    _panel.add_child(_scroll)

    # RichTextLabel(ログ表示)
    _label = RichTextLabel.new()
    _label.name = "Label"
    _label.bbcode_enabled = false
    _label.fit_content = true
    _label.scroll_active = false  # スクロールは ScrollContainer 側で行う

    # テキストカラー・フォントサイズを設定
    var theme := Theme.new()
    var font := DynamicFontData.new()
    # デフォルトフォントを使う(カスタムフォントを使いたい場合はここを書き換える)
    # Godot 4 では Theme / Font 設定の仕方が変わっているので注意
    var font_res := SystemFont.new()
    font_res.font_names = ["Noto Sans", "Arial", "sans-serif"]
    theme.set_font("font", "RichTextLabel", font_res)
    theme.set_font_size("font_size", "RichTextLabel", font_size)
    theme.set_color("default_color", "RichTextLabel", font_color)
    _label.theme = theme

    _scroll.add_child(_label)


# =========================
# ログ追加処理
# =========================
func _add_line(text: String) -> void:
    # フィルタ処理
    if include_filter != "" and not text.contains(include_filter):
        return
    if exclude_filter != "" and text.contains(exclude_filter):
        return

    _lines.append(text)
    # 古いログを削除
    if _lines.size() > max_lines:
        _lines.remove_at(0)

    _label.text = "\n".join(_lines)
    await get_tree().process_frame
    # 一番下までスクロール
    _scroll.scroll_vertical = _scroll.get_v_scroll_bar().max_value


# =========================
# print / エラー出力のフック
# =========================

func _hook_print_functions() -> void:
    # 既存の print 系を退避して、上書きする
    if _original_print.is_null():
        _original_print = funcref(self, "_original_print_impl")
    if _original_printerr.is_null():
        _original_printerr = funcref(self, "_original_printerr_impl")

    # すでにフック済みなら何もしない
    if Engine.has_singleton("LogConsolePrintHook"):
        return

    # シングルトン的に利用するため、AutoLoad 風のオブジェクトを登録
    var hook := Node.new()
    hook.name = "LogConsolePrintHook"
    Engine.get_main_loop().root.add_child(hook)

    # グローバル関数をラップするのではなく、LogConsole 側から明示的に使ってもらう方式にする
    # (Godot 4 ではグローバル print の完全上書きは推奨されない)
    # → 代わりに、print() を置き換えたい場合は、プロジェクト側で以下のようにする:
    #   - 自前の log() 関数を作り、print と _add_line の両方を呼ぶ
    # ここでは、標準の print の出力も拾うために、Logger シグナルを利用する。

    # Engine の logging 接続(Godot 4.2 以降で利用可能な LogHandler 想定)
    if Engine.has_singleton("GodotLogger"):
        var logger = Engine.get_singleton("GodotLogger")
        if not logger.is_connected("message_logged", Callable(self, "_on_engine_message_logged")):
            logger.connect("message_logged", Callable(self, "_on_engine_message_logged"))


func _restore_print_functions() -> void:
    # Engine ロガーから切断
    if Engine.has_singleton("GodotLogger"):
        var logger = Engine.get_singleton("GodotLogger")
        if logger.is_connected("message_logged", Callable(self, "_on_engine_message_logged")):
            logger.disconnect("message_logged", Callable(self, "_on_engine_message_logged"))


func _hook_error_functions() -> void:
    # Godot のエラー系をフックするのはバージョン依存が強いので、
    # ここでは push_error / push_warning をラップするユーティリティ関数を提供する形にする。
    # 実際のエンジン内部エラーは Engine の logger から拾う。
    pass


func _restore_error_functions() -> void:
    pass


# Engine ロガーからのコールバック(存在する場合のみ呼ばれる想定)
func _on_engine_message_logged(message: String, level: int, _tag: String) -> void:
    # level の例(仮): 0=Debug, 1=Info, 2=Warning, 3=Error
    var prefix := match level:
        0: "[DEBUG] "
        1: "[INFO ] "
        2: "[WARN ] "
        3: "[ERROR]"
        _: "[LOG  ] "
    _add_line("%s %s" % [prefix, message])


# =========================
# 補助: 手動でログを送るための API
# =========================

## 明示的に LogConsole にログを送るためのヘルパー。
## 通常の print に加えて、ゲーム画面にも出したいときに使う。
func log(message: String) -> void:
    print(message)
    _add_line(message)


## エラー用のヘルパー。
func log_error(message: String) -> void:
    push_error(message)
    _add_line("[ERROR] " + message)


## 警告用のヘルパー。
func log_warning(message: String) -> void:
    push_warning(message)
    _add_line("[WARN ] " + message)


# ダミー実装(退避用の元関数として保持しているだけ)
func _original_print_impl(_args: Variant) -> void:
    pass

func _original_printerr_impl(_args: Variant) -> void:
    pass

※ 上記では、Godot 4.2 以降で導入予定(またはカスタムプラグインで実装される想定)の GodotLogger シングルトンを利用した例も含めています。
実プロジェクトでは、ひとまず log() / log_error() / log_warning() を自前で呼ぶ運用でも十分に役に立ちます。


使い方の手順

  1. スクリプトを保存する
    上記コードを res://addons/log_console/LogConsole.gd など好きな場所に保存します。
    class_name LogConsole を付けているので、エディタから直接ノードとして追加できます。
  2. シーンに LogConsole を追加する
    たとえばゲーム全体のルートシーン(Main や GameRoot)にアタッチするのがおすすめです。
    ノード構成はこんなイメージになります。
    Main (Node)
     ├── Player (CharacterBody2D)
     │    ├── Sprite2D
     │    └── CollisionShape2D
     ├── EnemySpawner (Node)
     └── LogConsole (CanvasLayer)  ← コンポーネントとして 1 個置くだけ
        

    このように、ログ表示機能はゲームロジックから完全に独立したコンポーネントとして扱えます。

  3. 表示・挙動をカスタマイズする
    インスペクタで以下のようなパラメータを調整しましょう。
    • console_width / console_height … コンソール窓のサイズ
    • margin_left / margin_top … 画面のどこに出すか
    • max_lines … 保持するログ行数(重くなりすぎない程度に)
    • toggle_action … 表示/非表示を切り替える InputMap アクション名(デフォルトは ui_debug_console
    • start_visible … ゲーム開始時から出しておくか

    初回起動時にアクションが未登録なら F1 に自動で割り当てられます。必要に応じて InputMap から別キーに変更してください。

  4. ログを送ってみる
    そのままでも log() メソッドを使えばゲーム画面にログを出せます。
    
    # 例: Player.gd
    extends CharacterBody2D
    
    @onready var logger: LogConsole = get_tree().get_first_node_in_group("log_console") as LogConsole
    
    func _ready() -> void:
        if logger:
            logger.log("Player ready at position: %s" % [global_position])
    
    func _physics_process(_delta: float) -> void:
        # 何か条件を満たしたときだけログを出す
        if Input.is_action_just_pressed("ui_accept") and logger:
            logger.log("Jump requested at frame %d" % [Engine.get_frames_drawn()])
    

    LogConsole を簡単に見つけるために、LogConsole ノードに log_console グループを付けておくと便利です(インスペクタの「Node」タブ → Groups)。

例えば、敵の AI の状態遷移を可視化したい場合は、Enemy シーン側でこういう構成になります。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── EnemyAI (Node)        ← AI ロジック

LogConsole は Enemy シーンには含めず、ゲーム全体で 1 つだけ置いておき、EnemyAI から logger.log("state changed: ...") と呼ぶだけにしておくと、シーン構造がとてもシンプルに保てます。


メリットと応用

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

  • 「ログ表示のための UI」がゲームロジックから完全に分離
    Player や Enemy シーンを一切いじらずに、ログ表示機能だけを後付けできます。継承ベースで「DebugPlayer」「DebugEnemy」みたいなシーンを増やす必要がありません。
  • どのシーンでも同じコンポーネントを再利用
    プロジェクトをまたいでも、LogConsole.gd を 1 ファイルコピーすればすぐ使えます。
    シーン階層は Main → LogConsole だけで済むので、レベルデザインの邪魔になりません。
  • 実機デバッグが圧倒的に楽
    スマホや Web で動かしたときでも、画面上でログを確認できます。
    ちょっとした「チートコンソール」としても使えるので、QA やテストプレイにも便利ですね。
  • 本番ビルドでは簡単にオフれる
    LogConsole ノードを削除するか、visible = false にしておけば、UI としては一切存在しなくなります。コンポーネントなので、他のシーンに影響を与えません。

「深いノード階層にログ用 UI を埋め込む」のではなく、「ログ機能を 1 つのコンポーネントとしてルートに置くだけ」にすることで、シーン構造の見通しがかなり良くなるはずです。

改造案:コンソールコマンドを実装してみる

LogConsole を「見るだけ」から一歩進めて、簡易コマンド入力をつけるのも面白いです。
例えば、特定の文字列を入力するとデバッグ用のチートを実行する、というような仕組みですね。

以下は、log() の代わりに execute_command() を追加して、/tp 100 200 のようなコマンドをパースする簡単な例です。


## LogConsole に簡単なコマンド実行機能を追加する例
func execute_command(command: String) -> void:
    _add_line("> " + command)  # 入力したコマンドもログに残す

    var parts := command.strip_edges().split(" ")
    if parts.is_empty():
        return

    match parts[0]:
        "/clear":
            _lines.clear()
            _label.text = ""
        "/echo":
            if parts.size() > 1:
                var msg := " ".join(parts.slice(1, parts.size()))
                _add_line(msg)
        "/tp":
            # 例: /tp 100 200 でプレイヤーをテレポート(かなり雑な実装例)
            if parts.size() >= 3:
                var x := float(parts[1])
                var y := float(parts[2])
                var player := get_tree().get_first_node_in_group("player")
                if player and player.has_method("set_global_position"):
                    player.global_position = Vector2(x, y)
                    _add_line("Teleported player to (%s, %s)" % [x, y])
                else:
                    _add_line("[WARN ] Player not found or cannot be moved.")
        _:
            _add_line("[WARN ] Unknown command: %s" % [parts[0]])

このように、LogConsole を「見えるログ」だけでなく、「操作できるコンソール」として育てていくと、デバッグ体験が一気に快適になります。
継承ではなくコンポーネントとして実装しているおかげで、こうした機能追加も LogConsole.gd の中だけで完結するのが嬉しいところですね。