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_speedstart_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が作れるようになりますね。