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に反映されるのがいいですね。