Godotでクレジットを流したいとき、つい「シーンを1個作って、アニメーションでY座標を動かして…」みたいな実装をしてしまいがちですよね。
あるいは、ScrollContainer を使うにしても、毎フレーム scroll_vertical を自前で更新したり、アニメーションプレイヤーでキーを打ったり…。
「クレジット用のシーンをもう1個作るのもダルいし、スクロール速度を変えると全部作り直し」という状態になりやすいです。
そこで今回は、ScrollContainerにポン付けして、親を自動でゆっくり下にスクロールさせるだけのコンポーネントを用意しました。
クレジットだけでなく、ニュースティッカーやログビューアなど、「中身が長いUIを自動で下に流したい」ときにサクッと使えるようにしてあります。
【Godot 4】クレジットも放置で流れる!「AutoScroll」コンポーネント
今回のコンポーネントは、自分の親にある ScrollContainer を自動でゆっくり下にスクロールさせるだけ、というシンプルなものです。
ポイントは「ScrollContainer を継承しない」こと。
あくまで 独立した Node としてアタッチするだけなので、既存のUIシーンをほぼそのまま流用できます。
フルコード(GDScript / Godot 4)
extends Node
class_name AutoScroll
## 親の ScrollContainer を自動でゆっくり下にスクロールさせるコンポーネント。
## クレジットやログビューアなどにアタッチして使います。
@export_group("Scroll Settings")
## 1秒あたり何ピクセルスクロールするか(正の値で下方向)
@export_range(0.0, 2000.0, 1.0, "or_greater")
var scroll_speed: float = 50.0
## 自動スクロールを開始するまでの待ち時間(秒)
@export_range(0.0, 30.0, 0.1, "or_greater")
var start_delay: float = 0.5
## 端までスクロールしきったあと、停止するまでの余韻時間(秒)
## 0 の場合は端に着いた瞬間に on_scroll_finished を発火
@export_range(0.0, 30.0, 0.1, "or_greater")
var end_hold_time: float = 1.0
## 端まで到達したら自動でループさせるか
@export var loop: bool = false
## ループ時、先頭に戻る前に待つ時間(秒)
@export_range(0.0, 30.0, 0.1, "or_greater")
var loop_delay: float = 1.0
@export_group("Behavior")
## 親ノードを明示的に指定したい場合(通常は空のままでOK)
## 未指定の場合は親ツリーを辿って最初に見つけた ScrollContainer を使う
@export var target_scroll_container: ScrollContainer
## ゲームの一時停止(get_tree().paused)中もスクロールを続けるか
@export var process_when_paused: bool = false
## シーンが読み込まれたら自動でスクロールを開始するか
@export var auto_start: bool = true
@export_group("Debug")
## デバッグ用: 現在の状態をインスペクタで確認したいときにどうぞ
@export var debug_print_state: bool = false
## スクロールが最下端まで到達し、end_hold_time 経過後に発火するシグナル
signal scroll_finished
var _scroll: ScrollContainer
var _content_height: float = 0.0
var _view_height: float = 0.0
var _max_scroll: float = 0.0
var _elapsed_from_start: float = 0.0
var _elapsed_at_bottom: float = 0.0
var _elapsed_loop_wait: float = 0.0
var _is_running: bool = false
var _is_at_bottom: bool = false
var _is_in_loop_wait: bool = false
func _ready() -> void:
# 一時停止中も動かすかどうか
process_mode = Node.PROCESS_MODE_ALWAYS if process_when_paused else Node.PROCESS_MODE_INHERIT
# ScrollContainer の参照を解決
_resolve_scroll_container()
# スクロール可能な範囲を計算
_update_scroll_range()
if auto_start:
start()
func _process(delta: float) -> void:
if not _is_running or _scroll == null:
return
# ポーズ中に動かさない設定なら、ツリーがポーズ中のときは何もしない
if not process_when_paused and get_tree().paused:
return
# 開始ディレイ中
if _elapsed_from_start < start_delay:
_elapsed_from_start += delta
return
# ループ待機中
if _is_in_loop_wait:
_elapsed_loop_wait += delta
if _elapsed_loop_wait >= loop_delay:
# 先頭に戻して再開
_scroll.scroll_vertical = 0
_is_in_loop_wait = false
_is_at_bottom = false
_elapsed_at_bottom = 0.0
if debug_print_state:
print("[AutoScroll] loop restarted")
return
# 端までスクロール済みの場合、end_hold_time のカウントだけ進める
if _is_at_bottom:
if end_hold_time > 0.0:
_elapsed_at_bottom += delta
if _elapsed_at_bottom >= end_hold_time:
_emit_finished_and_handle_loop()
else:
# 余韻時間が 0 の場合は即終了扱い
_emit_finished_and_handle_loop()
return
# 通常のスクロール処理
var current = float(_scroll.scroll_vertical)
var next = current + scroll_speed * delta
if next >= _max_scroll:
# 一番下まで到達
_scroll.scroll_vertical = int(_max_scroll)
_is_at_bottom = true
_elapsed_at_bottom = 0.0
if debug_print_state:
print("[AutoScroll] reached bottom")
else:
_scroll.scroll_vertical = int(next)
func _notification(what: int) -> void:
# レイアウト変更時にスクロール範囲を再計算したい場合
if what == NOTIFICATION_VISIBILITY_CHANGED or what == NOTIFICATION_RESIZED:
_update_scroll_range()
## 外部から呼び出してスクロールを開始する
func start() -> void:
if _scroll == null:
push_warning("AutoScroll: ScrollContainer が見つからないため start() できません。")
return
_is_running = true
_is_at_bottom = false
_is_in_loop_wait = false
_elapsed_from_start = 0.0
_elapsed_at_bottom = 0.0
_elapsed_loop_wait = 0.0
if debug_print_state:
print("[AutoScroll] started")
## 外部から呼び出して一時停止する
func pause() -> void:
_is_running = false
if debug_print_state:
print("[AutoScroll] paused")
## 外部から呼び出して完全に停止し、先頭に戻す
func stop_and_reset() -> void:
_is_running = false
_is_at_bottom = false
_is_in_loop_wait = false
_elapsed_from_start = 0.0
_elapsed_at_bottom = 0.0
_elapsed_loop_wait = 0.0
if _scroll:
_scroll.scroll_vertical = 0
if debug_print_state:
print("[AutoScroll] stopped and reset")
## 内部: ScrollContainer の参照を見つける
func _resolve_scroll_container() -> void:
if target_scroll_container:
_scroll = target_scroll_container
return
# 親ツリーを辿って最初に見つかった ScrollContainer を使う
var current: Node = get_parent()
while current:
if current is ScrollContainer:
_scroll = current
break
current = current.get_parent()
if _scroll == null:
push_warning("AutoScroll: 親に ScrollContainer が見つかりません。AutoScroll は機能しません。")
## 内部: スクロール可能な範囲を計算
func _update_scroll_range() -> void:
if _scroll == null:
return
# ScrollContainer の子(通常は 1つ)からコンテンツの高さを取得
if _scroll.get_child_count() == 0:
_content_height = 0.0
else:
var content := _scroll.get_child(0)
if content is Control:
_content_height = (content as Control).size.y
else:
_content_height = 0.0
_view_height = _scroll.size.y
_max_scroll = max(0.0, _content_height - _view_height)
if debug_print_state:
print("[AutoScroll] content_height=%s, view_height=%s, max_scroll=%s"
% [_content_height, _view_height, _max_scroll])
## 内部: 端まで到達したあとの共通処理
func _emit_finished_and_handle_loop() -> void:
_is_running = false
emit_signal("scroll_finished")
if debug_print_state:
print("[AutoScroll] scroll_finished emitted")
if loop:
# ループする場合は待機状態に移行
_is_in_loop_wait = true
_elapsed_loop_wait = 0.0
if debug_print_state:
print("[AutoScroll] loop wait started")
使い方の手順
基本パターンとして、クレジット画面用のシーンを例にします。
CreditsScene (Control)
├── Panel
│ └── ScrollContainer
│ └── VBoxContainer
│ ├── Label ("STAFF")
│ ├── Label ("Programmer: ...")
│ ├── Label ("Artist: ...")
│ └── ...(たくさん並ぶ)
└── AutoScroll (Node)
手順①: ScrollContainerベースのUIを普通に作る
- 新しいシーンを
Controlで作成します(クレジット画面用)。 - その子に
Panel→ その中にScrollContainerを置きます。 ScrollContainerの中にVBoxContainerを1つ置き、その中にLabelをズラッと並べてクレジットテキストを配置します。
手順②: AutoScrollをアタッチする
- シーンのルート(
CreditsScene)直下にNodeを1つ追加し、スクリプトに上記のAutoScroll.gdをアタッチします。 - 名前を
AutoScrollにしておくと分かりやすいですね。 - 今回は
target_scroll_containerを空のままにしておきます。
コンポーネントは親ツリーを辿って自動で一番近いScrollContainerを見つけてくれます。
手順③: パラメータを調整する
インスペクタから以下を好みに合わせて設定しましょう。
scroll_speed: 1秒あたりのスクロール量。
例: 50〜80 あたりだとクレジットっぽいゆっくり感になります。start_delay: 画面表示後、スクロールが始まるまでの待ち時間。
例: 0.5〜1.0 秒にしておくと、いきなり動かずにちょっと見せてから流れ始めます。end_hold_time: 一番下まで行った後、止まったままにしておく時間。
例: 2.0 秒にしておくと「最後の1行」を少し読ませられます。loop: true にすると、最後まで行ったら先頭に戻ってループします。loop_delay: ループ時、先頭に戻る前の待ち時間。
手順④: シグナルで「クレジット終了」を検出する(任意)
クレジットが最後まで行ったら、タイトルに戻したい/シーンを切り替えたい、というケースが多いと思います。
そのために scroll_finished シグナルを用意してあります。
例えば、ルートの CreditsScene に以下のようなスクリプトを付けておきます。
extends Control
@onready var auto_scroll: AutoScroll = $AutoScroll
func _ready() -> void:
# クレジット終了時のコールバックを接続
auto_scroll.scroll_finished.connect(_on_scroll_finished)
func _on_scroll_finished() -> void:
# ここでタイトル画面に戻るなどの処理を行う
get_tree().change_scene_to_file("res://scenes/Title.tscn")
これで、コンポーネントに「クレジットのロジック」を閉じ込めつつ、外からはシグナルで完了を受け取る、というきれいな分離ができます。
別の使用例: ログビューアやチャットログの自動スクロール
同じコンポーネントを、例えば「ゲーム中のログ画面」にも流用できます。
LogWindow (Control)
├── Panel
│ └── ScrollContainer
│ └── VBoxContainer
│ ├── Label ("[00:01] Game started")
│ ├── Label ("[00:02] Player joined")
│ └── ...
└── AutoScroll (Node)
- ログを表示するシーンに
AutoScrollを足すだけで、「自動的に下に流れるログビューア」が完成します。 - 一時停止したいときは
auto_scroll.pause()を呼べばOKです。
メリットと応用
この「AutoScroll」コンポーネントの良いところは、ScrollContainer を継承していない点にあります。
- シーン構造がスッキリ
クレジット用に「特別な ScrollContainer クラス」を作る必要がなく、既存のUIにAutoScrollを1個足すだけで完結します。 - 使い回しが簡単
クレジット、ログビューア、ニュースティッカーなど、どこにでも同じスクリプトをアタッチして再利用できます。 - テストが楽
scroll_speedやstart_delayをインスペクタからいじるだけで挙動が変わるので、デザイナーやレベルデザイナーにも優しいです。 - ロジックの独立性が高い
「スクロールの開始・停止」「終了時のイベント」などのロジックが1つのコンポーネントに閉じているので、他のUIスクリプトと混ざらずに保守しやすくなります。
Godot標準だと「ScrollContainer を継承した MyCreditScrollContainer を作る」みたいな方向に行きがちですが、そこからさらに「オプション付きのバージョン」「ログ用バージョン」と増やしていくと、あっという間にクラス地獄になります。
そうではなく、ScrollContainer は素のままにしておいて、「スクロールさせる」という責務だけを AutoScroll に切り出すのが、合成(Composition)志向の設計ですね。
改造案: マウスホイールやキー入力で「一時的に手動スクロール」を許可する
例えば「自動で流れているけど、ユーザーが操作したら一旦止める」という仕様にしたい場合、次のような関数を追加できます。
## 入力があったら自動スクロールを一時停止する例
func handle_user_input(event: InputEvent) -> void:
if event is InputEventMouseButton or event.is_action_pressed("ui_up") or event.is_action_pressed("ui_down"):
# ユーザーがスクロール操作をしたとみなして、一時停止
pause()
これを AutoScroll 側で _unhandled_input(event) から呼んでもいいですし、CreditsScene のルートで入力を拾って auto_scroll.handle_user_input(event) を呼ぶ形にしてもOKです。
こうやって「自動スクロール」「ユーザー操作」「終了イベント」などをコンポーネント単位で組み合わせていくと、継承に頼らずに柔軟なUIが作れるようになりますね。
