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() を自前で呼ぶ運用でも十分に役に立ちます。
使い方の手順
- スクリプトを保存する
上記コードをres://addons/log_console/LogConsole.gdなど好きな場所に保存します。
class_name LogConsoleを付けているので、エディタから直接ノードとして追加できます。 - シーンに LogConsole を追加する
たとえばゲーム全体のルートシーン(Main や GameRoot)にアタッチするのがおすすめです。
ノード構成はこんなイメージになります。Main (Node) ├── Player (CharacterBody2D) │ ├── Sprite2D │ └── CollisionShape2D ├── EnemySpawner (Node) └── LogConsole (CanvasLayer) ← コンポーネントとして 1 個置くだけこのように、ログ表示機能はゲームロジックから完全に独立したコンポーネントとして扱えます。
- 表示・挙動をカスタマイズする
インスペクタで以下のようなパラメータを調整しましょう。console_width/console_height… コンソール窓のサイズmargin_left/margin_top… 画面のどこに出すかmax_lines… 保持するログ行数(重くなりすぎない程度に)toggle_action… 表示/非表示を切り替える InputMap アクション名(デフォルトはui_debug_console)start_visible… ゲーム開始時から出しておくか
初回起動時にアクションが未登録なら F1 に自動で割り当てられます。必要に応じて InputMap から別キーに変更してください。
- ログを送ってみる
そのままでも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 の中だけで完結するのが嬉しいところですね。
