GodotでチャットUIを組むとき、ついこんな構成になりがちですよね。
- ChatWindowシーンを作る
- その中にRichTextLabelを置く
- さらに「プレイヤーチャット用」「システムメッセージ用」「ログ保存用」…とシーンを継承して増やしていく
そして気づくと:
- 「スクロール位置を維持する処理」があちこちにコピペされる
- 古いログを削除するロジックが各シーンにバラバラに存在する
- UIを作り直したら、また同じログ管理コードを書き直す羽目になる
Godotはノード継承が強力ですが、「チャットログ管理」みたいな横断的なロジックは、継承で増やすよりもコンポーネントとして切り出してアタッチする方が圧倒的に楽です。
そこで登場するのが、今回のコンポーネント 「ChatLog」 です。
- RichTextLabelに対してメッセージをどんどん追記
- 一定行数・一定メッセージ数を超えたら古いログを自動削除
- スクロール位置の維持(「一番下に追従」 or 「ユーザーがスクロールしていたら追従しない」)を選択可能
これらをどのチャットUIにもポン付けできるコンポーネントとして実装していきましょう。
【Godot 4】チャット履歴はコンポーネントに丸投げ!「ChatLog」コンポーネント
GDScriptフルコード
extends Node
class_name ChatLog
## ChatLog (チャットログ) コンポーネント
## - RichTextLabel にチャットメッセージを追記
## - 古いメッセージを自動的に削除
## - スクロール位置の制御もおまかせ
## ログを表示する RichTextLabel
## - シーン側でアサインしてください
## - 未設定の場合は、親ノードから自動検索を試みます
@export var target_label: RichTextLabel
## 最大メッセージ数
## - この数を超えたら、古いメッセージから順に削除されます
@export var max_messages: int = 200
## RichTextLabel 上での最大行数
## - 0 の場合は「行数制限なし」
## - 長文が多いチャットの場合はこちらで制限するのもアリです
@export var max_lines: int = 0
## 新しいメッセージ追加時に、ログの一番下まで自動スクロールするか
## - true: 毎回一番下へスクロール
## - false: スクロール位置はユーザーに任せる
@export var auto_scroll_to_bottom: bool = true
## ユーザーが手動でスクロールしたら、自動スクロールを止めるか
## - true: ユーザーがスクロールバーを触ったら auto_scroll を一時停止
## - false: 常に auto_scroll_to_bottom の設定どおりに動作
@export var disable_auto_scroll_on_user_scroll: bool = true
## メッセージの区切り文字
## - メッセージ間に挿入される改行など
@export var message_separator: String = "\n"
## メッセージを保存する内部バッファ
var _messages: Array[String] = []
## ユーザーがスクロール操作をしたかどうか
var _user_scrolled: bool = false
## ラベルのスクロールバー(あれば)
var _v_scroll_bar: ScrollBar
func _ready() -> void:
## target_label が未設定なら、親ノード以下から自動で探す
if target_label == null:
target_label = _find_rich_text_label()
if target_label == null:
push_warning("ChatLog: RichTextLabel が見つかりませんでした。'target_label' を設定してください。")
return
## スクロールバーを取得
_setup_scroll_bar()
## 初期表示を同期
_refresh_label()
func _find_rich_text_label() -> RichTextLabel:
## 親ノード以下を走査して最初に見つかった RichTextLabel を返す
var root := get_parent()
if root == null:
return null
for child in root.get_children():
if child is RichTextLabel:
return child
if child is Node:
var found := _find_rich_text_label_recursive(child)
if found:
return found
return null
func _find_rich_text_label_recursive(node: Node) -> RichTextLabel:
for child in node.get_children():
if child is RichTextLabel:
return child
if child is Node:
var found := _find_rich_text_label_recursive(child)
if found:
return found
return null
func _setup_scroll_bar() -> void:
if not is_instance_valid(target_label):
return
## RichTextLabel の縦スクロールバーを取得
_v_scroll_bar = target_label.get_v_scroll_bar()
if _v_scroll_bar and disable_auto_scroll_on_user_scroll:
## ユーザー操作を検知するために value_changed シグナルを監視
_v_scroll_bar.value_changed.connect(_on_scroll_bar_value_changed)
func _on_scroll_bar_value_changed(value: float) -> void:
## 一番下から一定以上離れたら、ユーザーがスクロールしたとみなす
if not _v_scroll_bar:
return
var max_value := _v_scroll_bar.max_value
## ここでの閾値はお好みで調整可能
var threshold := 4.0
if max_value - value > threshold:
_user_scrolled = true
else:
## ほぼ一番下にいるので、再び自動スクロールを有効にする
_user_scrolled = false
## --- パブリックAPI ---------------------------------------------------------
## メッセージを追加する
## - text: 表示したい文字列(BBCode も使用可能)
## - prefix: "[System]" など、先頭に付けたいラベル
func add_message(text: String, prefix: String = "") -> void:
if prefix != "":
text = "%s %s" % [prefix, text]
_messages.append(text)
_trim_messages()
_refresh_label()
## すべてのメッセージを削除する
func clear_messages() -> void:
_messages.clear()
_refresh_label()
## 現在のメッセージ一覧を取得する
func get_messages() -> Array[String]:
return _messages.duplicate()
## --- 内部処理 -------------------------------------------------------------
func _trim_messages() -> void:
## メッセージ数で制限
if max_messages > 0 and _messages.size() > max_messages:
var overflow := _messages.size() - max_messages
_messages = _messages.slice(overflow, _messages.size())
## 行数で制限
if max_lines <= 0:
return
var text := _messages.join(message_separator)
var lines := text.split("\n")
if lines.size() > max_lines:
var overflow_lines := lines.size() - max_lines
lines = lines.slice(overflow_lines, lines.size())
text = "\n".join(lines)
## メッセージ単位には戻せないので、まとめて1つのメッセージとして扱う
_messages.clear()
_messages.append(text)
func _refresh_label() -> void:
if not is_instance_valid(target_label):
return
var was_at_bottom := _is_at_bottom()
## ラベルのテキストを再構築
target_label.clear()
var full_text := _messages.join(message_separator)
target_label.append_text(full_text)
## 自動スクロール
if auto_scroll_to_bottom and (not disable_auto_scroll_on_user_scroll or not _user_scrolled):
_scroll_to_bottom()
elif was_at_bottom:
## 直前まで一番下にいた場合は、軽く追従してあげる
_scroll_to_bottom()
func _is_at_bottom() -> bool:
if not _v_scroll_bar:
return true
var value := _v_scroll_bar.value
var max_value := _v_scroll_bar.max_value
var threshold := 4.0
return (max_value - value) <= threshold
func _scroll_to_bottom() -> void:
if not _v_scroll_bar:
return
await get_tree().process_frame
_v_scroll_bar.value = _v_scroll_bar.max_value
使い方の手順
ここからは、実際にシーンに組み込む手順を見ていきましょう。
手順①:チャットUIシーンを用意する
まずはシンプルなチャットウィンドウのシーンを作ります。
ChatWindow (Control) ├── Panel │ └── RichTextLabel ├── LineEdit # プレイヤーが入力するテキストボックス └── ChatLog (Node) # 今回のコンポーネント
- ChatWindow: ルートのControl
- RichTextLabel: チャット履歴を表示するラベル
- LineEdit: プレイヤーがメッセージを入力するUI
- ChatLog: 今回作ったコンポーネントを Node として追加
ChatLog ノードを選択し、インスペクタから以下を設定します。
- target_label: シーン内の RichTextLabel をドラッグ&ドロップ
- max_messages: 100 など、必要な履歴数
- max_lines: 0 のままか、長文チャットなら 300 などに設定
- auto_scroll_to_bottom: true(チャットなら基本オンでOK)
target_label を設定し忘れても、親ノード以下から自動で探してくれますが、明示的に設定しておくのが安全です。
手順②:ChatWindow から ChatLog にメッセージを送る
ChatWindow(ルートノード)にスクリプトを貼り付け、LineEdit のテキストを ChatLog に渡します。
extends Control
@onready var input_field: LineEdit = $LineEdit
@onready var chat_log: ChatLog = $ChatLog
func _ready() -> void:
## Enter キーでメッセージ送信
input_field.text_submitted.connect(_on_text_submitted)
func _on_text_submitted(text: String) -> void:
if text.strip_edges() == "":
return
## プレイヤーの発言として ChatLog に追加
chat_log.add_message(text, "[Player]")
## 入力欄をクリア
input_field.text = ""
これで、LineEdit に文字を打って Enter を押すと、ChatLog コンポーネント経由で RichTextLabel にメッセージが追記されます。
手順③:システムメッセージや敵NPCにも再利用する
同じ ChatLog を、システムメッセージや敵NPCの発言ログにも使い回せます。
例えば、敵NPCの「会話ログウィンドウ」を別シーンとして作る場合:
EnemyTalkWindow (Control) ├── Panel │ └── RichTextLabel └── ChatLog (Node)
敵AIのスクリプトから:
## 敵AIなどから呼び出すイメージ
func say_to_player(window: EnemyTalkWindow, text: String) -> void:
window.get_chat_log().add_message(text, "[Enemy]")
EnemyTalkWindow 側は ChatLog を持っているだけで、ログ管理ロジックは一切書かなくてOKです。
手順④:動く床・トリガーなどにも「ログ表示」を簡単追加
ゲーム内のギミックの説明をチャット風に出したいときも、継承せずに ChatLog をアタッチするだけで済みます。
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── ChatLog (Node)
例えば、プレイヤーが乗ったときに一言しゃべる動く床:
extends Node2D
@onready var chat_log: ChatLog = $ChatLog
func on_player_step() -> void:
chat_log.add_message("乗り心地はどうだい?", "[Platform]")
UIとゲームロジックを継承でくっつけず、「ログを表示したいノードに ChatLog を貼る」だけで済むのがコンポーネント指向の良さですね。
メリットと応用
この「ChatLog」コンポーネントを使うと、次のようなメリットがあります。
- シーン構造がスッキリ
「PlayerChatWindow」「EnemyChatWindow」「SystemLogWindow」みたいに継承ツリーを増やす必要がなく、
どのウィンドウにも同じ ChatLog コンポーネントをアタッチするだけで済みます。 - ログ管理ロジックの一元化
「古いログの削除」「スクロール位置制御」といった面倒な処理が1か所にまとまるので、
仕様変更(ログ件数を増やす、スクロール挙動を変えるなど)にも強いです。 - UI差し替えが簡単
RichTextLabel の位置や見た目を変えても、ChatLog の設定を変えずにそのまま使い回せます。
最悪、別シーンに ChatLog ノードをコピペするだけです。 - ゲーム中の「ログっぽいもの」に全部流用できる
戦闘ログ、デバッグログ、チュートリアルメッセージ、NPC会話ログなど、
「テキストを貯めて表示する」系は全部これで統一できます。
「継承でチャットウィンドウのバリエーションを増やす」のではなく、
「どんなウィンドウにも ChatLog コンポーネントを付ける」という発想に切り替えると、
プロジェクト全体の見通しがかなり良くなります。
改造案:メッセージにタイムスタンプを自動付与する
例えば、「すべてのメッセージに時刻を付けたい」場合は、ChatLog にこんな関数を追加するだけでOKです。
## ローカル時刻付きでメッセージを追加するヘルパー
func add_timestamped_message(text: String, prefix: String = "") -> void:
var time := Time.get_time_dict_from_system()
var time_str := "%02d:%02d:%02d" % [time.hour, time.minute, time.second]
var stamped_text := "[%s] %s" % [time_str, text]
add_message(stamped_text, prefix)
この改造をしておけば、ゲーム内では:
chat_log.add_timestamped_message("ログインしました", "[System]")
のように呼ぶだけで、
[12:34:56] [System] ログインしました
といった形式のログが自動で積み上がっていきます。
コンポーネントとして切り出しておくと、こうした「ちょっとした仕様変更」も
継承ツリーとにらめっこすることなく、1ファイルをいじるだけで全チャットUIに反映されるのがいいですね。
