Godot 4で2Dゲームを作っていると、「部屋制ステージ」をやりたくなること、ありますよね。ゼルダっぽく、プレイヤーが画面端まで行ったら、カメラがスッと隣の部屋へスライドして切り替わるあの感じです。

素直に実装しようとすると、こんな悩みが出てきます。

  • シーンごとにカメラの位置やリミットを個別に調整するのが面倒
  • プレイヤーのスクリプトに「部屋切り替えロジック」を書き始めてしまい、肥大化する
  • カメラの補間や入力ロックなどを毎回コピペしてバグの温床に…

Godotはノード継承で作っていくのが標準ですが、「カメラ付きプレイヤー」「部屋移動プレイヤー」みたいに派生クラスを増やしていくと、後から挙動を変えたいときに地獄を見ます。そこで今回は「RoomTransition」コンポーネントとして、プレイヤーやカメラに「後付けできる部屋切り替え機能」として切り出してみましょう。

【Godot 4】レトロな部屋切り替えをコンポーネント化!「RoomTransition」コンポーネント

このコンポーネントは、ざっくりいうと:

  • グリッド状に並んだ「部屋」を想定(1部屋 = 一定サイズの矩形)
  • プレイヤーが画面端に到達したら、カメラを隣の部屋へスライド
  • スライド中は入力ロックなどもできる

という役割を持つ、汎用の「部屋切り替えマネージャ」ノードです。プレイヤー本体やCamera2Dには最小限の責務だけを持たせ、部屋移動のルールはこのコンポーネントに集約してしまいましょう。

フルコード:RoomTransition.gd


extends Node
class_name RoomTransition
"""
RoomTransition コンポーネント
- プレイヤーが画面端に到達したら、カメラを隣の部屋へスライドさせる。
- 「1部屋 = 一定サイズの矩形」として扱い、グリッド状に部屋が並んでいる前提。

想定する使い方:
- 親シーンに配置して、プレイヤー(Node)とCamera2Dをインスペクタから紐付ける。
- 部屋サイズや有効な部屋数を設定する。
"""

# === 基本設定 ===

@export var player: Node2D
## 部屋を移動する対象。通常はプレイヤー(CharacterBody2Dなど)を指定します。
## グローバル座標を元に「どの部屋にいるか」を判定します。

@export var camera: Camera2D
## 部屋切り替え時にスライドさせるカメラ。
## Playerに追従させず、このコンポーネントがカメラ位置を管理する想定です。

@export var room_size: Vector2 = Vector2(320, 180)
## 1部屋あたりのサイズ(ピクセル)。
## カメラがこのサイズ分だけスライドして、隣の部屋へ移動します。
## 例: 320x180, 640x360 など。

@export var room_grid_size: Vector2i = Vector2i(3, 3)
## 部屋が何マス分並んでいるか(列数 x 行数)。
## 例: 3x3 なら、(0,0)〜(2,2) の9部屋が存在する想定です。

@export var transition_time: float = 0.5
## カメラが隣の部屋へスライドするのにかける時間(秒)。
## 0.0 にすると即座にワープします。

@export var freeze_player_during_transition: bool = true
## 部屋切り替え中にプレイヤーの入力を無効化するかどうか。
## プレイヤー側に「set_process_input(false)」などを持つ前提で、シグナルを使って制御します。

@export var auto_snap_player_inside_room: bool = true
## 部屋切り替え後に、プレイヤーを部屋の内側に少しだけ押し戻しておくかどうか。
## 画面端に引っかかったままにならないようにするためのオプションです。

@export var screen_margin: float = 4.0
## 「画面端に到達した」とみなすマージン。
## プレイヤーの位置がカメラ中心から (room_size.x / 2 - margin) 以上離れたら部屋切り替えを開始します。


# === シグナル ===

signal transition_started(target_room: Vector2i)
## 部屋切り替えが始まったときに発火。UIのフェードなどに使えます。

signal transition_finished(target_room: Vector2i)
## 部屋切り替えが完了したときに発火。

signal player_control_enabled(enabled: bool)
## プレイヤーの入力をON/OFFしたいときに使う汎用シグナル。
## プレイヤー側のスクリプトで、このシグナルを受けて入力処理を止めるなどの対応をします。


# === 内部状態 ===

var _current_room: Vector2i = Vector2i.ZERO
## 現在の部屋インデックス(グリッド座標)

var _is_transitioning: bool = false
## カメラ移動中かどうか

var _tween: Tween
## カメラ移動用のTween


func _ready() -> void:
    # 必要な参照が揃っているか軽くチェック
    if not player:
        push_warning("RoomTransition: 'player' が設定されていません。インスペクタから設定してください。")
    if not camera:
        push_warning("RoomTransition: 'camera' が設定されていません。インスペクタから設定してください。")

    # 初期部屋をプレイヤー位置から推定
    if player:
        _current_room = _get_room_index_from_position(player.global_position)
        _snap_camera_to_room(_current_room)

    # 既存Tweenがあれば削除
    if _tween:
        _tween.kill()
    _tween = null


func _physics_process(delta: float) -> void:
    if not player or not camera:
        return

    if _is_transitioning:
        return

    # プレイヤーが今いる部屋インデックスを計算
    var room_index := _get_room_index_from_position(player.global_position)

    # カメラの中心位置(現在の部屋の中心)を計算
    var room_center := _get_room_center_position(_current_room)
    var local_pos := player.global_position - room_center

    # 画面端のしきい値(左右・上下)
    var half_w := room_size.x * 0.5 - screen_margin
    var half_h := room_size.y * 0.5 - screen_margin

    var target_room := _current_room

    # 左端
    if local_pos.x < -half_w:
        target_room.x -= 1
    # 右端
    elif local_pos.x > half_w:
        target_room.x += 1

    # 上端
    if local_pos.y < -half_h:
        target_room.y -= 1
    # 下端
    elif local_pos.y > half_h:
        target_room.y += 1

    # 部屋が変わるかどうか
    if target_room != _current_room:
        # グリッド外なら無視
        if _is_room_index_valid(target_room):
            _start_transition(target_room)


# === 内部ロジック ===

func _get_room_index_from_position(pos: Vector2) -> Vector2i:
    # 原点(0,0)からのオフセットを部屋サイズで割ってインデックス化
    var x := int(floor(pos.x / room_size.x))
    var y := int(floor(pos.y / room_size.y))
    return Vector2i(x, y)


func _get_room_center_position(room_index: Vector2i) -> Vector2:
    # グリッド座標から部屋中心のワールド座標を計算
    return Vector2(
        (room_index.x + 0.5) * room_size.x,
        (room_index.y + 0.5) * room_size.y
    )


func _is_room_index_valid(room_index: Vector2i) -> bool:
    # 0〜room_grid_size-1 の範囲に収まっているかチェック
    if room_index.x < 0 or room_index.y < 0:
        return false
    if room_index.x >= room_grid_size.x or room_index.y >= room_grid_size.y:
        return false
    return true


func _snap_camera_to_room(room_index: Vector2i) -> void:
    # カメラを指定した部屋の中心に一発で移動
    if not camera:
        return
    camera.global_position = _get_room_center_position(room_index)


func _start_transition(target_room: Vector2i) -> void:
    if _is_transitioning:
        return

    _is_transitioning = true
    emit_signal("transition_started", target_room)

    if freeze_player_during_transition:
        emit_signal("player_control_enabled", false)

    var start_pos := camera.global_position
    var end_pos := _get_room_center_position(target_room)

    # 既存Tweenを殺して新しく作る
    if _tween:
        _tween.kill()
    _tween = create_tween()
    _tween.set_trans(Tween.TRANS_SINE)
    _tween.set_ease(Tween.EASE_IN_OUT)

    if transition_time <= 0.0:
        # 即座にワープ
        camera.global_position = end_pos
        _on_transition_finished(target_room)
        return

    _tween.tween_property(camera, "global_position", end_pos, transition_time)
    _tween.finished.connect(func():
        _on_transition_finished(target_room)
    )


func _on_transition_finished(target_room: Vector2i) -> void:
    _current_room = target_room
    _is_transitioning = false

    # プレイヤーを部屋の内側に少し押し戻す(任意)
    if auto_snap_player_inside_room and player:
        var room_center := _get_room_center_position(_current_room)
        var local_pos := player.global_position - room_center
        var half_w := room_size.x * 0.5 - screen_margin
        var half_h := room_size.y * 0.5 - screen_margin

        # X方向の補正
        if local_pos.x < -half_w:
            local_pos.x = -half_w + 1.0
        elif local_pos.x > half_w:
            local_pos.x = half_w - 1.0

        # Y方向の補正
        if local_pos.y < -half_h:
            local_pos.y = -half_h + 1.0
        elif local_pos.y > half_h:
            local_pos.y = half_h - 1.0

        player.global_position = room_center + local_pos

    if freeze_player_during_transition:
        emit_signal("player_control_enabled", true)

    emit_signal("transition_finished", target_room)


# === デバッグ用: エディタ上で部屋の境界を可視化したいとき ===

@export var debug_draw_gizmos: bool = false
## エディタ上で部屋グリッドを線で描画するかどうか。

@export var debug_color: Color = Color(0, 1, 0, 0.5)
## グリッド線の色。

func _draw() -> void:
    if not debug_draw_gizmos:
        return

    var total_w := room_size.x * room_grid_size.x
    var total_h := room_size.y * room_grid_size.y

    # 垂直線
    for x in range(room_grid_size.x + 1):
        var px := x * room_size.x
        draw_line(Vector2(px, 0), Vector2(px, total_h), debug_color, 1.0)

    # 水平線
    for y in range(room_grid_size.y + 1):
        var py := y * room_size.y
        draw_line(Vector2(0, py), Vector2(total_w, py), debug_color, 1.0)


func _process(delta: float) -> void:
    if Engine.is_editor_hint():
        queue_redraw()

使い方の手順

ここからは、具体的に「プレイヤー + カメラ + RoomTransition」で部屋切り替えを実現する手順を見ていきましょう。

例1:プレイヤーが部屋を移動する2Dアクション

想定シーン構成はこんな感じです:

MainScene (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── PlayerController (スクリプト)
 ├── Camera2D
 └── RoomTransition (Node)

手順①:RoomTransitionコンポーネントをシーンに追加

  1. 上記の RoomTransition.gd をプロジェクトに保存します(例: res://components/RoomTransition.gd)。
  2. メインシーン(ステージ全体を表すシーン)を開き、ルート(または管理用のNode2D)の子として Node を1つ追加し、スクリプトに RoomTransition.gd をアタッチします。
  3. インスペクタで
    • player に Player ノードをドラッグ&ドロップ
    • camera に Camera2D ノードをドラッグ&ドロップ
    • room_size を画面解像度と同じにする(例: 320×180, 640×360)
    • room_grid_size を部屋の数に合わせる(例: 横3 x 縦2なら Vector2i(3,2))

手順②:Camera2D は RoomTransition に任せる

Camera2D をプレイヤーに追従させる代わりに、RoomTransitionがカメラの位置を直接制御します。なので:

  • Camera2D の position は (0, 0) でOK
  • Player の子に Camera2D を置かず、シーンのルート直下などに配置しておきます

カメラの初期位置は、RoomTransition が _ready() でプレイヤーの位置から自動で部屋を推定し、その部屋の中心にスナップします。

手順③:プレイヤーの入力制御と連携する

RoomTransitionは、部屋切り替え中に入力を止めたい場合に player_control_enabled シグナルを飛ばします。プレイヤーのコントローラスクリプト側でこれを受けて、入力処理をON/OFFしましょう。


# PlayerController.gd(例)
extends CharacterBody2D

var _control_enabled: bool = true

func _ready() -> void:
    # シーンツリー上から RoomTransition を探してシグナル接続
    var room_transition := get_tree().get_first_node_in_group("room_transition")
    if room_transition:
        room_transition.player_control_enabled.connect(_on_player_control_enabled)

func _on_player_control_enabled(enabled: bool) -> void:
    _control_enabled = enabled

func _physics_process(delta: float) -> void:
    if not _control_enabled:
        velocity = Vector2.ZERO
        move_and_slide()
        return

    # 通常の移動処理
    var input_dir := Vector2.ZERO
    input_dir.x = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
    input_dir.y = Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
    input_dir = input_dir.normalized()

    velocity = input_dir * 200.0
    move_and_slide()

上の例では、RoomTransitionノードを「room_transition」というグループに入れておく前提です(インスペクタの「Node」タブ → Groups で設定)。もちろん、直接ノードパスで取得してもOKです。

手順④:ステージ(部屋)をタイルマップで作る

最後に、実際のステージを作ります。RoomTransitionは「ワールド座標 = 部屋グリッド」というシンプルな前提で動いているので、以下のように設計するのが楽です。

MainScene (Node2D)
 ├── TileMap (部屋の床・壁を描く)
 ├── Player (CharacterBody2D)
 ├── Camera2D
 └── RoomTransition (Node)
  • 原点 (0,0) から右方向に room_size.x ごと、下方向に room_size.y ごとに部屋を配置
  • 例: room_size = (320, 180), room_grid_size = (3, 2) の場合



      • 部屋(0,0): x: 0〜320, y: 0〜180

      • 部屋(1,0): x: 320〜640, y: 0〜180

      • 部屋(0,1): x: 0〜320, y: 180〜360





プレイヤーを好きな部屋の中に配置してゲームを実行すると、画面端に到達したタイミングでカメラがスライドして隣の部屋へ移動するはずです。

メリットと応用

このRoomTransitionコンポーネントを使うメリットは、何よりも責務の分離です。

  • プレイヤーのスクリプトは「移動とアクション」に集中できる
  • カメラのスライドや入力ロック、部屋インデックス管理はすべて RoomTransition に集約
  • 別のシーン(別ステージ)でも、RoomTransitionノードをコピペして設定を変えるだけで再利用できる
  • 「このシーンはスクロール追従型」「このシーンは部屋切り替え型」といった切り替えも、カメラコンポーネントを入れ替えるだけで対応しやすい

Godot標準の「プレイヤーにCamera2Dを子としてぶら下げる」やり方だと、カメラ挙動を変えたいときにプレイヤーの階層やスクリプトをいじる必要が出てきます。カメラはカメラ、部屋管理はRoomTransition、プレイヤーはプレイヤーと役割を分けておくことで、シーン構造もスッキリしますし、後からの差し替えも楽になりますね。

改造案:部屋切り替え時にフェードイン・アウトを入れる

よりレトロ感を出したいなら、部屋切り替え中に画面フェードを入れるのも良いですね。簡単な改造案として、RoomTransitionに以下のようなフックを追加して、外部のUIフェードコンポーネントと連携させることができます。


# RoomTransition.gd の一部を差し替え・追記するイメージ

@export var use_fade_effect: bool = false
@export var fade_controller_path: NodePath
## フェード制御用のノード(例: CanvasLayer 上の FadeController)へのパス。

func _start_transition(target_room: Vector2i) -> void:
    if _is_transitioning:
        return

    _is_transitioning = true
    emit_signal("transition_started", target_room)

    if freeze_player_during_transition:
        emit_signal("player_control_enabled", false)

    var start_pos := camera.global_position
    var end_pos := _get_room_center_position(target_room)

    if use_fade_effect and fade_controller_path != NodePath():
        var fade_controller = get_node_or_null(fade_controller_path)
        if fade_controller and fade_controller.has_method("play_room_transition"):
            fade_controller.play_room_transition(func():
                # フェードアウト中にカメラを瞬間移動し、フェードインで見せる
                camera.global_position = end_pos
                _on_transition_finished(target_room)
            )
            return

    # 既存のTween処理はそのまま
    if _tween:
        _tween.kill()
    _tween = create_tween()
    _tween.set_trans(Tween.TRANS_SINE)
    _tween.set_ease(Tween.EASE_IN_OUT)
    if transition_time <= 0.0:
        camera.global_position = end_pos
        _on_transition_finished(target_room)
        return
    _tween.tween_property(camera, "global_position", end_pos, transition_time)
    _tween.finished.connect(func():
        _on_transition_finished(target_room)
    )

このように、RoomTransition は「部屋切り替えのタイミングを決めるコンポーネント」として設計しておけば、フェードやSE再生、敵のリセットなどは外部のコンポーネントに任せて、シグナルやコールバックで連携させるだけで済みます。継承ではなく合成で機能を積み上げていくと、プロジェクトの拡張性がかなり変わってきますよ。